mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Merge audited upstream for raw B1 qualification
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
07061a601c
211 files changed
+16449
-4667
No files matched your search
@@ -65,6 +65,14 @@ runs:
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
|
||||
- name: Replay workspace install hooks after restore
|
||||
if: steps.installed.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
pnpm -r --if-present run postinstall
|
||||
pnpm run postinstall
|
||||
|
||||
- name: Restore certified shared build outputs (read only)
|
||||
if: inputs.restore-cache == 'true' && steps.inputs.outputs.cacheable == 'true'
|
||||
id: builds
|
||||
|
||||
@@ -71,6 +71,36 @@ test('unmodelled workspace lifecycle scripts disable materialized reuse', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('known Copilot hooks permit caching, track their helpers, and still run on restore', () => {
|
||||
const files = { ...entries,
|
||||
'server/package.json': JSON.stringify({ scripts: { postinstall: 'node scripts/install-copilot-assets.mjs' } }),
|
||||
'packages/paperclip-runner/package.json': JSON.stringify({ scripts: { postinstall: 'node scripts/install-copilot-assets.mjs' } }),
|
||||
'server/scripts/install-copilot-assets.mjs': 'server bootstrap',
|
||||
'packages/paperclip-runner/scripts/install-copilot-assets.mjs': 'runner bootstrap',
|
||||
'packages/paperclip-runner/scripts/materialize-copilot-binary.mjs': 'native materializer',
|
||||
'packages/paperclip-runner/scripts/copilot-inner-distribution.mjs': 'closure pins',
|
||||
};
|
||||
const compute = (input) => cachePlan({ files: Object.keys(input),
|
||||
read: file => input[file] == null ? null : Buffer.from(input[file]), scope,
|
||||
packageDirs: ['.', 'server', 'packages/paperclip-runner'],
|
||||
});
|
||||
const original = compute(files);
|
||||
assert.equal(original.cacheable, true);
|
||||
for (const file of Object.keys(files).filter(file => /^(server|packages\/paperclip-runner)\/scripts\//.test(file))) {
|
||||
for (const replacement of ['changed', null]) {
|
||||
assert.notEqual(compute({ ...files, [file]: replacement }).dependencyKey, original.dependencyKey, file);
|
||||
}
|
||||
}
|
||||
assert.equal(cachePlan({ files: ['elsewhere/package.json'],
|
||||
read: () => Buffer.from(files['server/package.json']), scope, packageDirs: ['elsewhere'],
|
||||
}).cacheable, false, 'the command is allowed only in its reviewed owners');
|
||||
const action = readFileSync(new URL('../../actions/setup-ci-dependencies/action.yml', import.meta.url), 'utf8');
|
||||
const replay = action.split(' - name: Replay workspace install hooks after restore')[1].split(' - name:')[0];
|
||||
assert.match(replay, /if: steps\.installed\.outputs\.cache-hit == 'true'/);
|
||||
assert.match(replay, /pnpm -r --if-present run postinstall\s+pnpm run postinstall/);
|
||||
assert.ok(action.indexOf('Replay workspace install hooks') < action.indexOf('Validate workspace links'));
|
||||
});
|
||||
|
||||
test('cache restores are exact and only a frozen canonical master push can publish entries', () => {
|
||||
const action = readFileSync(new URL('../../actions/setup-ci-dependencies/action.yml', import.meta.url), 'utf8');
|
||||
const installed = action.slice(action.indexOf(' - name: Restore installed'), action.indexOf(' - name: Locate pnpm'));
|
||||
@@ -97,6 +127,15 @@ test('paired test timings remain available when lifecycle hooks disable material
|
||||
assert.match(step(name), /if: steps.qualification.outputs.cacheable == 'true'/);
|
||||
}
|
||||
assert.match(step('Explain disabled materialized reuse'), /cacheable != 'true'/);
|
||||
assert.doesNotMatch(step('Prepare unchanged baseline'), /if:/);
|
||||
assert.doesNotMatch(step('Compare complete commands and executed case inventories'), /if:/);
|
||||
for (const name of ['Prepare unchanged baseline', 'Compare complete commands and executed case inventories']) {
|
||||
assert.match(step(name), /if: steps\.test_scope\.outputs\.run == 'true'/);
|
||||
assert.doesNotMatch(step(name), /if:.*cacheable/);
|
||||
}
|
||||
const scope = step('Select changed server test work');
|
||||
assert.match(scope, /git diff --name-only HEAD\^1 HEAD --/);
|
||||
for (const file of ['scripts/benchmark-ci-work.mjs', 'scripts/run-vitest-stable.mjs',
|
||||
'server/src/__tests__/setup-supertest.ts', 'server/src/__tests__/workspace-runtime.test.ts',
|
||||
'server/src/services/workspace-git-snapshot-streaming.test.ts']) assert.ok(scope.includes(file));
|
||||
assert.doesNotMatch(scope, /scripts\/ci-dependency-cache|\.github\/actions\/setup-ci-dependencies/);
|
||||
assert.match(step('Exercise restored dependencies for cache-only changes'), /pnpm exec vitest run --project @paperclipai\/shared/);
|
||||
});
|
||||
@@ -10,7 +10,8 @@ const base = {
|
||||
ref: "refs/heads/master", event_name: "push", sha, run_attempt: 1,
|
||||
};
|
||||
const expectedJobs = {
|
||||
"cloud-readiness.yml": [],
|
||||
"docker.yml": ["source_verification", "publication_verification"],
|
||||
"cloud-readiness.yml": ["admission", "source_verified"],
|
||||
"cloud-migrator-artifacts.yml": [],
|
||||
"release-verify.yml": ["typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"],
|
||||
"runner-chaos-evals.yml": ["chaos_and_recovery"],
|
||||
@@ -59,9 +60,9 @@ for (const [file, expectedNames] of Object.entries(expectedJobs)) {
|
||||
cases.push({ name: "stable release", inputs: { channel: "stable" } });
|
||||
}
|
||||
cases.push({ name: "first attempt as a string", github: { run_attempt: "1" }, expected: fleet });
|
||||
cases.push({ name: "rerun after runner interruption", github: { run_attempt: 2 } });
|
||||
cases.push({ name: "later rerun", github: { run_attempt: 3 } });
|
||||
cases.push({ name: "rerun attempt as a string", github: { run_attempt: "2" } });
|
||||
cases.push({ name: "admitted retry", github: { run_attempt: 2 }, expected: release ? "ubuntu-latest" : fleet });
|
||||
cases.push({ name: "later retry", github: { run_attempt: 3 }, expected: release ? "ubuntu-latest" : fleet });
|
||||
cases.push({ name: "retry attempt as a string", github: { run_attempt: "2" }, expected: release ? "ubuntu-latest" : fleet });
|
||||
cases.push({ name: "missing attempt", github: { run_attempt: undefined } });
|
||||
for (const { name, github = {}, inputs: overrides = {}, enabled = "true", expected = "ubuntu-latest" } of cases) {
|
||||
test(`${file}/${job}: ${name}`, () => {
|
||||
@@ -88,7 +89,7 @@ for (const [file, expectedNames] of Object.entries(expectedJobs)) {
|
||||
}
|
||||
|
||||
|
||||
test("Cloud readiness bookkeeping never waits for the AWS verification fleet", () => {
|
||||
test("Cloud source proof keeps source-only gating on protected verification capacity", () => {
|
||||
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
|
||||
const bodies = new Map();
|
||||
for (const [name, needs] of [
|
||||
@@ -97,7 +98,7 @@ test("Cloud readiness bookkeeping never waits for the AWS verification fleet", (
|
||||
const body = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z_]+:|(?![\\s\\S]))`, "m"))?.[1];
|
||||
assert.ok(body, `missing ${name} job`);
|
||||
bodies.set(name, body);
|
||||
assert.match(body, /^ runs-on: ubuntu-latest$/m);
|
||||
assert.match(body, /runs-on:.*paperclip-post-merge-x64/);
|
||||
assert.doesNotMatch(body, /^ +continue-on-error:|^ +if:.*always\(\)/m);
|
||||
assert.match(body, /^ if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'$/m);
|
||||
assert.match(body, /^ +SOURCE_SHA: \$\{\{ github.sha \}\}$/m);
|
||||
|
||||
@@ -48,11 +48,27 @@ test("every rust-cache restore in the PR workflow belongs to a guarded reader jo
|
||||
const keyInputs = [
|
||||
/uses: Swatinem\/rust-cache@([0-9a-f]{40}) # v[0-9.]+/,
|
||||
/workspaces: (\$\{\{ steps\.runner_rust_workspace\.outputs\.path \}\} -> target)/,
|
||||
/shared-key: (release-runner-v2)/,
|
||||
/shared-key: (release-runner-v2-.+)/,
|
||||
/cache-workspace-crates: (false)/,
|
||||
/cache-bin: (false)/,
|
||||
];
|
||||
|
||||
test("virtual workspace profiles and ancestor Cargo configuration partition every dependency cache", () => {
|
||||
for (const [name, workflow] of [["PR", prWorkflow], ["release", releaseWorkflow]]) {
|
||||
const keys = [...workflow.matchAll(/^\s+shared-key: (.+)$/gm)].map(([, key]) => key);
|
||||
assert.ok(keys.length > 0);
|
||||
for (const key of keys) {
|
||||
assert.ok(key.includes("hashFiles('packages/paperclip-runner/runner/Cargo.toml'"), name);
|
||||
for (const directory of ['.', 'packages', 'packages/paperclip-runner', 'packages/paperclip-runner/runner']) {
|
||||
const prefix = directory === '.' ? '' : `${directory}/`;
|
||||
for (const file of ['config', 'config.toml']) {
|
||||
assert.ok(key.includes(`'${prefix}.cargo/${file}'`), `${name}: ${prefix}.cargo/${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every PR reader restores the Rust cache under the same key the master push writes", () => {
|
||||
for (const [name, pr] of readers) {
|
||||
for (const pattern of keyInputs) {
|
||||
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
- .github/actions/setup-ci-dependencies/**
|
||||
- .github/workflows/ci-work-benchmark.yml
|
||||
- scripts/ci-dependency-cache.mjs
|
||||
- .github/workflows/pr-trusted.yml
|
||||
- .github/workflows/release-verify.yml
|
||||
- scripts/runner-debug-crate-cache.mjs
|
||||
- scripts/benchmark-ci-work.mjs
|
||||
- scripts/run-vitest-stable.mjs
|
||||
- server/src/__tests__/setup-supertest.ts
|
||||
@@ -39,6 +42,21 @@ jobs:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
- name: Select changed server test work
|
||||
id: test_scope
|
||||
run: |
|
||||
changed=$(git diff --name-only HEAD^1 HEAD -- \
|
||||
scripts/benchmark-ci-work.mjs \
|
||||
scripts/run-vitest-stable.mjs \
|
||||
server/src/__tests__/setup-supertest.ts \
|
||||
server/src/__tests__/workspace-runtime.test.ts \
|
||||
server/src/services/workspace-git-snapshot-streaming.test.ts)
|
||||
if [ -n "$changed" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
@@ -123,6 +141,7 @@ jobs:
|
||||
echo "Materialized dependency reuse is disabled by an unmodelled install hook. Retaining cold setup and comparing test commands only." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Prepare unchanged baseline
|
||||
if: steps.test_scope.outputs.run == 'true'
|
||||
run: |
|
||||
base_sha=$(git rev-parse HEAD^1)
|
||||
git worktree add --detach ../baseline "$base_sha"
|
||||
@@ -135,12 +154,17 @@ jobs:
|
||||
pnpm --filter @paperclipai/plugin-sdk ensure-build-deps
|
||||
|
||||
- name: Compare complete commands and executed case inventories
|
||||
if: steps.test_scope.outputs.run == 'true'
|
||||
env:
|
||||
BENCHMARK_OUTPUT: ${{ runner.temp }}/ci-work-benchmark
|
||||
run: |
|
||||
node scripts/benchmark-ci-work.mjs ../baseline "$BENCHMARK_OUTPUT"
|
||||
cat "$BENCHMARK_OUTPUT/summary.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Exercise restored dependencies for cache-only changes
|
||||
if: steps.test_scope.outputs.run != 'true' && steps.qualification.outputs.cacheable == 'true'
|
||||
run: pnpm exec vitest run --project @paperclipai/shared
|
||||
|
||||
- name: Retain timings, inventories and failure logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
@@ -148,3 +172,120 @@ jobs:
|
||||
name: ci-work-benchmark
|
||||
path: ${{ runner.temp }}/ci-work-benchmark
|
||||
retention-days: 14
|
||||
|
||||
rust_prime:
|
||||
if: >-
|
||||
contains(github.event.pull_request.labels.*.name, 'profile-ci') &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'profile-ci')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Select the pinned compiler and workspace path
|
||||
id: workspace
|
||||
working-directory: packages/paperclip-runner
|
||||
run: |
|
||||
toolchain=$(rustup show active-toolchain | awk '{print $1}')
|
||||
echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV"
|
||||
rustup toolchain list | awk '{print $1}' | grep -vx "$toolchain" | xargs -r -n1 rustup toolchain uninstall || true
|
||||
pinned="$HOME/paperclip-runner-rust"
|
||||
ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned"
|
||||
echo "path=$pinned" >> "$GITHUB_OUTPUT"
|
||||
- name: Qualify the dependency cache
|
||||
id: dependencies
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: ${{ steps.workspace.outputs.path }} -> target
|
||||
shared-key: qualification-${{ github.run_id }}-${{ github.run_attempt }}-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', '.cargo/config', '.cargo/config.toml', 'packages/.cargo/config', 'packages/.cargo/config.toml', 'packages/paperclip-runner/.cargo/config', 'packages/paperclip-runner/.cargo/config.toml', 'packages/paperclip-runner/runner/.cargo/config', 'packages/paperclip-runner/runner/.cargo/config.toml') }}
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
save-if: 'true'
|
||||
- name: Build the exact debug units
|
||||
working-directory: packages/paperclip-runner
|
||||
run: cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins
|
||||
- name: Compute workspace artifact identity
|
||||
id: debug
|
||||
run: node scripts/runner-debug-crate-cache.mjs plan
|
||||
- name: Save workspace artifacts in this PR only
|
||||
id: artifacts
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
key: qualification-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.debug.outputs.key }}
|
||||
path: ${{ steps.debug.outputs.paths }}
|
||||
|
||||
rust_restore:
|
||||
needs: rust_prime
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Select the pinned compiler and workspace path
|
||||
id: workspace
|
||||
working-directory: packages/paperclip-runner
|
||||
run: |
|
||||
toolchain=$(rustup show active-toolchain | awk '{print $1}')
|
||||
echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV"
|
||||
rustup toolchain list | awk '{print $1}' | grep -vx "$toolchain" | xargs -r -n1 rustup toolchain uninstall || true
|
||||
pinned="$HOME/paperclip-runner-rust"
|
||||
ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned"
|
||||
echo "path=$pinned" >> "$GITHUB_OUTPUT"
|
||||
- name: Qualify the dependency cache
|
||||
id: dependencies
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: ${{ steps.workspace.outputs.path }} -> target
|
||||
shared-key: qualification-${{ github.run_id }}-${{ github.run_attempt }}-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', '.cargo/config', '.cargo/config.toml', 'packages/.cargo/config', 'packages/.cargo/config.toml', 'packages/paperclip-runner/.cargo/config', 'packages/paperclip-runner/.cargo/config.toml', 'packages/paperclip-runner/runner/.cargo/config', 'packages/paperclip-runner/runner/.cargo/config.toml') }}
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
save-if: 'false'
|
||||
- name: Compute workspace artifact identity
|
||||
id: debug
|
||||
run: node scripts/runner-debug-crate-cache.mjs plan
|
||||
- name: Restore workspace artifacts in this PR only
|
||||
id: artifacts
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
key: qualification-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.debug.outputs.key }}
|
||||
path: ${{ steps.debug.outputs.paths }}
|
||||
- name: Verify fresh-host cache reuse
|
||||
env:
|
||||
DEPENDENCIES_HIT: ${{ steps.dependencies.outputs.cache-hit }}
|
||||
ARTIFACTS_HIT: ${{ steps.artifacts.outputs.cache-hit }}
|
||||
run: |
|
||||
test "$DEPENDENCIES_HIT" = true
|
||||
test "$ARTIFACTS_HIT" = true
|
||||
node scripts/runner-debug-crate-cache.mjs mark-current
|
||||
cd packages/paperclip-runner
|
||||
started=$(date +%s)
|
||||
cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins --message-format=json > "$RUNNER_TEMP/rust-restored-build.jsonl"
|
||||
echo "Restored Rust build: $(($(date +%s) - started)) seconds." >> "$GITHUB_STEP_SUMMARY"
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync } from 'node:fs';
|
||||
const artifacts = readFileSync(`${process.env.RUNNER_TEMP}/rust-restored-build.jsonl`, 'utf8')
|
||||
.trim().split('\n').map(line => JSON.parse(line)).filter(row => row.reason === 'compiler-artifact');
|
||||
if (!artifacts.length || artifacts.some(row => !row.fresh)) {
|
||||
throw new Error(`Cache hit rebuilt units: ${JSON.stringify(artifacts.filter(row => !row.fresh).map(row => row.package_id))}`);
|
||||
}
|
||||
console.log(`All ${artifacts.length} Cargo units were fresh on a new host.`);
|
||||
NODE
|
||||
- name: Retain Cargo reuse evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: rust-cache-qualification
|
||||
path: ${{ runner.temp }}/rust-restored-build.jsonl
|
||||
retention-days: 14
|
||||
@@ -12,9 +12,33 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
admission:
|
||||
name: Cloud image candidate admission v1
|
||||
if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.CORE_PUBLICATION_RUNNER || 'cloud-release-32' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
admitted: ${{ steps.admission.outputs.admitted }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: scripts
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
- id: admission
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/cloud-release-admission.mjs image-only
|
||||
|
||||
build:
|
||||
needs: admission
|
||||
if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && needs.admission.outputs.admitted == 'true'
|
||||
runs-on: ${{ vars.CORE_PUBLICATION_RUNNER || 'cloud-release-32' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -52,7 +76,7 @@ jobs:
|
||||
publish:
|
||||
needs: build
|
||||
if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.CORE_PUBLICATION_RUNNER || 'cloud-release-32' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -8,14 +8,38 @@ on:
|
||||
|
||||
permissions: {}
|
||||
|
||||
# Source verification must start outside the full npm release's queue.
|
||||
# Source verification stays independent of npm publication and image builds.
|
||||
concurrency:
|
||||
group: cloud-readiness-${{ github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
admission:
|
||||
name: Cloud candidate admission v1
|
||||
if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master'
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
admitted: ${{ steps.admission.outputs.admitted }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: scripts
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
- id: admission
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/cloud-release-admission.mjs
|
||||
|
||||
verify:
|
||||
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
|
||||
needs: admission
|
||||
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master' && needs.admission.outputs.admitted == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-verify.yml
|
||||
@@ -29,8 +53,9 @@ jobs:
|
||||
name: Cloud source verified v1
|
||||
needs: [verify]
|
||||
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
|
||||
# Bookkeeping must not wait for the AWS builders it observes.
|
||||
runs-on: ubuntu-latest
|
||||
# Keep the final proof off the shared hosted queue too. The protected
|
||||
# fleet has a separate job ceiling and launches Spot workers on demand.
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -33,6 +33,29 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
source_verification:
|
||||
name: Watch exact-source verification
|
||||
if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master'
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 44
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
outputs:
|
||||
verified: ${{ steps.watch.outputs.verified }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Stop images when source checks fail and report runner waits
|
||||
id: watch
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/watch-release-source.mjs
|
||||
|
||||
reuse_canary_image:
|
||||
if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && startsWith(github.ref, 'refs/tags/canary/v')
|
||||
runs-on: ubuntu-latest
|
||||
@@ -100,11 +123,10 @@ jobs:
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# Native builds land well inside this; it is a backstop, not a budget.
|
||||
# (The interim fix while this PR landed raised the single QEMU job's cap
|
||||
# to 120 minutes; native per-arch legs make that headroom unnecessary.)
|
||||
timeout-minutes: 60
|
||||
runs-on: ${{ vars.CORE_RELEASE_AWS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && format('runs-on/fleet=paperclip-core-image-{0}/env=public-ci', matrix.arch == 'amd64' && 'x64' || 'arm64') || matrix.runner }}
|
||||
# Stop before the AWS worker's 45-minute maximum lifetime. Hosted tag
|
||||
# builds retain their existing limit.
|
||||
timeout-minutes: ${{ vars.CORE_RELEASE_AWS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && 40 || 60 }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -115,6 +137,9 @@ jobs:
|
||||
# Full history and tags so `git describe` below can compute the
|
||||
# release version to stamp into the image.
|
||||
fetch-depth: 0
|
||||
# Keep the version's full commit graph without downloading blobs
|
||||
# from every historical revision of this large repository.
|
||||
filter: blob:none
|
||||
|
||||
# `.git` is dockerignored, so a running image cannot derive its own
|
||||
# version and otherwise reports the source package.json placeholder in
|
||||
@@ -183,6 +208,7 @@ jobs:
|
||||
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
|
||||
|
||||
- name: Free runner disk
|
||||
if: ${{ !(vars.CORE_RELEASE_AWS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Disk before cleanup:"
|
||||
@@ -304,16 +330,40 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Wait for proof on ephemeral verification capacity. Publication runners do
|
||||
# only registry/signing work. Each Spot observer stays below its 45m lifetime.
|
||||
publication_verification:
|
||||
name: Require exact-source verification
|
||||
if: ${{ !cancelled() && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && needs.build-and-push.result == 'success' && needs.source_verification.result == 'success' && needs.source_verification.outputs.verified != 'true' }}
|
||||
needs: [build-and-push, source_verification]
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 44
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # restart only exact-source producers deferred before admission
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Require source proof and migrator admission before publication
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/watch-release-source.mjs --require-proof
|
||||
|
||||
# Names the per-architecture digests as one manifest list under the real
|
||||
# tags. Nothing is publicly tagged until this runs, so a half-published
|
||||
# multi-arch image is not a state anything can pull.
|
||||
merge-and-push:
|
||||
if: ${{ !cancelled() && needs.build-and-push.result == 'success' }}
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && needs.build-and-push.result == 'success' && (needs.source_verification.result == 'success' || needs.source_verification.result == 'skipped') && (needs.publication_verification.result == 'success' || (needs.publication_verification.result == 'skipped' && (github.repository != 'paperclipai/paperclip' || github.repository_id != '1170821064' || github.ref != 'refs/heads/master' || needs.source_verification.outputs.verified == 'true'))) }}
|
||||
needs: [build-and-push, source_verification, publication_verification]
|
||||
runs-on: ${{ github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (vars.CORE_PUBLICATION_RUNNER || 'cloud-release-32') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
@@ -322,8 +372,6 @@ jobs:
|
||||
# script the verification step pipes in.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -375,6 +423,19 @@ jobs:
|
||||
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
|
||||
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# Revalidate the latest attempt after runner assignment without waiting
|
||||
# here. A concurrent source retry must invalidate the earlier proof.
|
||||
- name: Revalidate exact-source proof before publication
|
||||
if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/watch-release-source.mjs --check-proof
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
|
||||
@@ -370,7 +370,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
shared-key: release-runner-v2-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', '.cargo/config', '.cargo/config.toml', 'packages/.cargo/config', 'packages/.cargo/config.toml', 'packages/paperclip-runner/.cargo/config', 'packages/paperclip-runner/.cargo/config.toml', 'packages/paperclip-runner/runner/.cargo/config', 'packages/paperclip-runner/runner/.cargo/config.toml') }}
|
||||
# Mirror the master writer: these also feed the cache key.
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
@@ -784,7 +784,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
shared-key: release-runner-v2-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', '.cargo/config', '.cargo/config.toml', 'packages/.cargo/config', 'packages/.cargo/config.toml', 'packages/paperclip-runner/.cargo/config', 'packages/paperclip-runner/.cargo/config.toml', 'packages/paperclip-runner/runner/.cargo/config', 'packages/paperclip-runner/runner/.cargo/config.toml') }}
|
||||
# Mirror the master writer: these also feed the cache key.
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
@@ -1035,7 +1035,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
shared-key: release-runner-v2-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', '.cargo/config', '.cargo/config.toml', 'packages/.cargo/config', 'packages/.cargo/config.toml', 'packages/paperclip-runner/.cargo/config', 'packages/paperclip-runner/.cargo/config.toml', 'packages/paperclip-runner/runner/.cargo/config', 'packages/paperclip-runner/runner/.cargo/config.toml') }}
|
||||
# Mirror the master writer: these also feed the cache key.
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
@@ -1376,7 +1376,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
shared-key: release-runner-v2-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', '.cargo/config', '.cargo/config.toml', 'packages/.cargo/config', 'packages/.cargo/config.toml', 'packages/paperclip-runner/.cargo/config', 'packages/paperclip-runner/.cargo/config.toml', 'packages/paperclip-runner/runner/.cargo/config', 'packages/paperclip-runner/runner/.cargo/config.toml') }}
|
||||
# Mirror the master writer: these also feed the cache key.
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
typecheck:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Typecheck
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-typecheck-v2
|
||||
shared-key: release-typecheck-v2-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', '.cargo/config', '.cargo/config.toml', 'packages/.cargo/config', 'packages/.cargo/config.toml', 'packages/paperclip-runner/.cargo/config', 'packages/paperclip-runner/.cargo/config.toml', 'packages/paperclip-runner/runner/.cargo/config', 'packages/paperclip-runner/runner/.cargo/config.toml') }}
|
||||
# Rebuild workspace code and rerun every check. Cache only compiled
|
||||
# dependencies; never restore installed executables from cargo/bin.
|
||||
cache-workspace-crates: false
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
general_tests:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: General tests (${{ matrix.group_label }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
serialized_tests:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Serialized tests (${{ matrix.shard_label }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
runner_workflow_evals:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Runner workflow eval scorer contract
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -313,7 +313,7 @@ jobs:
|
||||
verify_paperclip_runner:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Verify Paperclip Runner (${{ matrix.lane }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -398,7 +398,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
shared-key: release-runner-v2-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', '.cargo/config', '.cargo/config.toml', 'packages/.cargo/config', 'packages/.cargo/config.toml', 'packages/paperclip-runner/.cargo/config', 'packages/paperclip-runner/.cargo/config.toml', 'packages/paperclip-runner/runner/.cargo/config', 'packages/paperclip-runner/runner/.cargo/config.toml') }}
|
||||
# Rebuild workspace code and rerun every check. Cache only compiled
|
||||
# dependencies; never restore installed executables from cargo/bin.
|
||||
cache-workspace-crates: false
|
||||
@@ -523,7 +523,7 @@ jobs:
|
||||
build:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Build
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -680,7 +680,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
||||
with:
|
||||
workspaces: ${{ steps.native_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-native-v2-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/src/**', 'packages/paperclip-runner/*.json') }}
|
||||
shared-key: release-native-v2-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', '.cargo/config', '.cargo/config.toml', 'packages/.cargo/config', 'packages/.cargo/config.toml', 'packages/paperclip-runner/.cargo/config', 'packages/paperclip-runner/.cargo/config.toml', 'packages/paperclip-runner/runner/.cargo/config', 'packages/paperclip-runner/runner/.cargo/config.toml') }}-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/src/**', 'packages/paperclip-runner/*.json') }}
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
cache-on-failure: false
|
||||
|
||||
@@ -320,12 +320,12 @@ jobs:
|
||||
|
||||
verify_canary:
|
||||
name: Reuse exact-source verification
|
||||
if: github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
timeout-minutes: 70
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
actions: write # restart only an exact-source push deferred before admission
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
|
||||
@@ -20,8 +20,8 @@ concurrency:
|
||||
jobs:
|
||||
chaos_and_recovery:
|
||||
name: Restart, replay, trace, and recovery faults
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 40 || 45 }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt >= 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 40 || 45 }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
+17
-1
@@ -1095,6 +1095,22 @@ verifying Vite/HMR behavior.
|
||||
|
||||
For normal issue work, start with the smallest targeted check that proves the change. Reserve repo-wide typecheck/build/test runs for PR-ready handoff or changes broad enough that narrow checks do not cover the risk.
|
||||
|
||||
The Runner transport suite keeps attachment recovery, warm attachment, live
|
||||
adoption, and suspension in separate test files. Vitest can schedule these
|
||||
independent fixtures on its existing file workers; cases within each file stay
|
||||
serial. The package sequencer starts the ten files measured above 50 seconds in
|
||||
the November 4, 2026 AWS run first, ordered by approximate duration. This includes
|
||||
transport deadlines, settlement, provider launch, recovery, and streaming waits,
|
||||
so fresh CI runners do not have to infer duration from file size. Package CI uses
|
||||
up to four workers, bounded by available CPUs; local runs retain Vitest defaults.
|
||||
Vitest's file selection and shard membership are unchanged. Real lease/deadline
|
||||
waits and assertions are unchanged. Run the complete
|
||||
family, including the separate deadline and settlement regressions, with:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run runnerd-codex-transport
|
||||
```
|
||||
|
||||
#### Embedded Postgres template cache
|
||||
|
||||
To run the suite with one owned PostgreSQL server and a separate disposable
|
||||
@@ -3187,7 +3203,7 @@ Run the credential-free real-process restart suite with:
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner build:runner-binaries
|
||||
pnpm exec vitest run server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/live/runnerd-codex-transport.test.ts -t 'adopts a live runner'
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/live/runnerd-codex-transport-adoption.test.ts -t 'adopts a live runner'
|
||||
```
|
||||
|
||||
The suite uses isolated PostgreSQL state, isolated `PAPERCLIP_HOME` roots, real
|
||||
|
||||
@@ -368,7 +368,14 @@ readiness can succeed. A failed lane does not cancel the other lane.
|
||||
Both lanes restore Cargo dependencies with the pinned Rust Cache action. The
|
||||
compiler comes from the Runner package's `rust-toolchain.toml` before the action
|
||||
computes its key. Compiler and Cargo metadata changes select a new cache. The
|
||||
`release-runner-v2` shared key avoids separate copies for these lanes.
|
||||
`release-runner-v2` shared key avoids separate copies for these lanes. Its suffix
|
||||
hashes the virtual workspace's `runner/Cargo.toml` and ancestor Cargo configuration.
|
||||
Rust Cache discovers member manifests through Cargo metadata, which omits this
|
||||
virtual manifest. Without the suffix, changing `[profile.dev]` can restore an
|
||||
exact but obsolete dependency cache on every run. GitHub caches are immutable,
|
||||
so the successful rebuild cannot replace that old entry. The profile/configuration
|
||||
hash also partitions fallback restores. Every reader and writer uses the same
|
||||
inputs, including the separate typecheck and cross-target caches.
|
||||
Only the Rust lane saves this cache. After verification it also runs `build:rust`
|
||||
to warm the debug dependencies used by the protocol lane; its own tests already
|
||||
warm release dependencies. The cache writer is shorter than the protocol lane.
|
||||
@@ -385,13 +392,36 @@ paths and the PR lanes can restore master's entry. The guard tests under
|
||||
Rust cache step in both workflows.
|
||||
|
||||
Workspace crates and installed Cargo binaries are excluded from this entry.
|
||||
Every run runs all assigned checks, including on a cache hit. Only an
|
||||
own-repository master-push run verifying that push's exact SHA can restore the
|
||||
cache, and only a successful Rust lane saves it. PR, tag, and manual candidate
|
||||
Every run runs all assigned checks, including on a cache hit. In release
|
||||
verification, only an own-repository master-push run verifying that push's exact
|
||||
SHA can restore the cache, and only a successful Rust lane saves it. The PR
|
||||
workflow restores master's cache read only. Tag and manual candidate release
|
||||
verification compile without this cache. A miss or eviction costs compilation
|
||||
time but does not change the checks. To discard old dependency caches, increment
|
||||
the shared-key version and let the next successful master verification warm it.
|
||||
|
||||
### Installed JavaScript dependencies
|
||||
|
||||
`setup-ci-dependencies` can restore exact `node_modules` trees and separately
|
||||
certified shared/SDK build outputs. `scripts/ci-dependency-cache.mjs` hashes
|
||||
manifests, resolution, patches, install scripts, and the runtime/checkout layout.
|
||||
The root workspace-link hook and the server/Runner Copilot asset hooks are known
|
||||
postinstall commands. An exact dependency restore replays these hooks before
|
||||
validating workspace links and build outputs. Copilot defers installation in an
|
||||
unbuilt source checkout; its normal build installs and verifies the assets.
|
||||
Provider assets are not included in the dependency cache. Any unrecognized
|
||||
workspace lifecycle hook still disables materialized reuse. Only a frozen install
|
||||
from the canonical master push can publish the shared entries.
|
||||
|
||||
For CI cache changes, the `profile-ci` label runs the paired setup benchmark
|
||||
and exercises restored dependencies with the shared-package tests. The longer
|
||||
server test comparison runs when its measured scripts or fixtures change;
|
||||
cache-only changes do not require unchanged test commands to run faster.
|
||||
It also builds Rust units on one GitHub-hosted runner and restores the dependency
|
||||
and workspace caches on a second runner with a fresh checkout. Both restores
|
||||
must be exact, and Cargo must report every unit as fresh. These qualification
|
||||
caches are scoped to the PR and run; they cannot populate master's caches.
|
||||
|
||||
### Runner release crate cache
|
||||
|
||||
The dependency entry leaves the Runner's own crate out, and a fresh checkout
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
# Cloud build readiness
|
||||
|
||||
The `Cloud readiness` workflow starts for every master push and retains the
|
||||
versioned `Cloud source verified v1` job. It calls the full `Release Verify`
|
||||
workflow for that exact commit, including typecheck, builds, general and
|
||||
serialized tests, and Runner verification. The source proof depends on every
|
||||
source check and fails closed if verification fails, is cancelled, or is skipped.
|
||||
The `Cloud readiness` workflow starts a small admission check for each master
|
||||
push. Full verification starts only when the exact source has a running master
|
||||
Docker or automatic npm release workflow. Superseded pending sources avoid the
|
||||
full test matrix. The migrator producer similarly waits for image admission.
|
||||
|
||||
A selected consumer restarts a previously deferred push run, preserving its
|
||||
original source and push identity. This restarts only a successful admission
|
||||
job followed entirely by skipped jobs. A failed check, cancelled job, incomplete
|
||||
listing, or identity mismatch cannot trigger this automatic retry. Explicit
|
||||
manual runs and whole-workflow retries request full verification directly.
|
||||
|
||||
Admitted source runs retain the versioned `Cloud source verified v1` job. They
|
||||
call the full `Release Verify` workflow for that exact commit, including
|
||||
typecheck, builds, general and serialized tests, and Runner verification. The
|
||||
proof depends on every source check and fails closed if verification fails, is
|
||||
cancelled, or is skipped. Consumers accept only the latest attempt's proof.
|
||||
|
||||
The recurring public `-cloud` publisher and its `Cloud deployable v1` gate are
|
||||
retired. The workflow no longer builds a legacy image or waits for one.
|
||||
Keep its filename and source-proof job name stable: npm canary publication and
|
||||
downstream image composers consume that exact contract.
|
||||
|
||||
Standard images still publish independently through `docker.yml`. The
|
||||
Standard images build in parallel through `docker.yml`. Master image publication
|
||||
waits for the exact-source proof; a failed source check cancels its image build
|
||||
without waiting for the remaining test shards. The
|
||||
`cloud-migrator-artifacts.yml` workflow still publishes signed exact-source
|
||||
migrators independently. A downstream composer must verify those artifacts,
|
||||
build and test its own image, and record separate deployment readiness.
|
||||
|
||||
Verification runs outside the full npm release's concurrency group. Different
|
||||
commits have independent groups. The npm canary release reuses the source proof
|
||||
Verification runs outside the full npm release's concurrency group. Each source retains its independent verification run. The npm canary release reuses the source proof
|
||||
for its exact master push instead of starting another `Release Verify` run.
|
||||
Stable releases and candidate-branch betas still run full verification.
|
||||
|
||||
The canary consumer requires the expected workflow ID and path, upstream source
|
||||
repository, master push event, full SHA, and a successful job in the latest run
|
||||
attempt. It checks the run again after reading the jobs to reject a concurrent
|
||||
rerun. Missing proof waits for up to 45 minutes; failed, skipped, cancelled,
|
||||
rerun. Missing proof waits for up to 60 minutes; failed, skipped, cancelled,
|
||||
ambiguous, or mismatched proof cannot authorize publication. API failures fail
|
||||
closed. If a source check fails, fix it and rerun Cloud readiness before retrying
|
||||
the release. Use **Re-run all jobs** when a later attempt did not rerun the source
|
||||
@@ -120,15 +132,16 @@ excluded or sleeping targets, retries, and failures with the fleet result. Recor
|
||||
runner queue conditions and cache state; one warm or cold run is a sample, not a
|
||||
latency guarantee.
|
||||
|
||||
## Reserved AWS verification capacity
|
||||
## Dedicated AWS verification capacity
|
||||
|
||||
`AWS_POST_MERGE_CI_ENABLED=true` routes cloud source verification and
|
||||
exact-master migrator preparation to the
|
||||
`paperclip-post-merge` runner group. The separate Fleet label is
|
||||
`runs-on/fleet=paperclip-post-merge-x64/env=public-ci`. Its 36 reserved slots use
|
||||
the same four-vCPU, 16-GiB machines as approved PR jobs. PR capacity is reduced
|
||||
to 64; the separately provisioned image capacity is unchanged by this retirement.
|
||||
This keeps PR bursts from consuming every post-merge verification slot.
|
||||
`runs-on/fleet=paperclip-post-merge-x64/env=public-ci`. The protected fleet uses
|
||||
separate 8-vCPU, 32-GiB Spot workers that launch only when jobs arrive. Its
|
||||
job ceiling and public PR capacity are managed independently. There are no
|
||||
hot or stopped pools. Quota ceilings do not reserve hardware; measure cold
|
||||
launch and runner assignment times as well as job duration.
|
||||
|
||||
Every selector checks the canonical repository name and ID, master ref, and a
|
||||
push or manual event. Reusable verification also requires `inputs.ref` to equal
|
||||
@@ -142,7 +155,8 @@ its trusted-publisher identity.
|
||||
Before enabling the switch, deploy the separate Fleet and restrict its GitHub
|
||||
runner group to repository ID `1170821064` and these workflows at
|
||||
`refs/heads/master`: `cloud-readiness.yml`,
|
||||
`release-verify.yml`, `runner-chaos-evals.yml`, and `release.yml`. Do not authorize
|
||||
`release-verify.yml`, `runner-chaos-evals.yml`, `release.yml`, and `docker.yml`.
|
||||
The Docker workflow uses this group for its exact-source monitor and proof wait. Do not authorize
|
||||
PR-controlled workflow versions. The direct migrator producer always uses
|
||||
GitHub-hosted runners and needs no AWS runner-group authorization. PR placement retains its independent pinned
|
||||
workflow and six-account author/actor allowlist.
|
||||
@@ -158,6 +172,43 @@ route a public image job after this retirement. This source change does not
|
||||
delete runner groups, Fleets, credentials, registry images, or cache tags. Review
|
||||
shared infrastructure ownership separately before removing those resources.
|
||||
|
||||
## Protected standard-image builders
|
||||
|
||||
`CORE_RELEASE_AWS_ENABLED=true` routes canonical master image jobs to native
|
||||
`paperclip-core-image-x64` and `paperclip-core-image-arm64` fleets. Each has two
|
||||
32-vCPU / 128-GiB Spot slots, for four large image jobs in total. Workers
|
||||
launch for jobs and terminate afterward; no idle pool is maintained. The `paperclip-core-release` runner group permits only
|
||||
Core's `docker.yml@refs/heads/master`. PRs, branches, and tag releases keep their
|
||||
existing GitHub-hosted placement. Disable the switch and rerun the whole image
|
||||
workflow to restore hosted placement.
|
||||
|
||||
Image checkout retains the complete commit graph for version stamping but filters
|
||||
out historical blobs. The manifest job needs only a shallow source checkout.
|
||||
Prepared AWS image disks skip the standard hosted runner's disk cleanup. Native
|
||||
architecture builds, digest publication, the orphan-reaping smoke test, and
|
||||
attestation remain required. The source monitor has Actions write permission to cancel its own canonical
|
||||
master Docker run and failed exact-source verification siblings. It never
|
||||
automatically retries a failed source check. Its guarded retry permission can
|
||||
restart only an exact-source producer deferred before full admission. It checks run, attempt, SHA,
|
||||
repository, ref, and workflow identity before cancellation. Its bounded
|
||||
observation window does not cancel healthy pending checks. A successful observer
|
||||
records proof for publication immediately. If healthy checks remain pending after
|
||||
40 minutes, a separate required proof job waits for up to another 40 minutes on
|
||||
the verification fleet. Both jobs fit within the Spot worker lifetime. Neither
|
||||
wait occupies the four-slot publication pool. The hosted manifest job requires
|
||||
successful source proof and admitted migrator work before it starts; a queued
|
||||
migrator job alone is not evidence of admission. It retains its 20-minute budget
|
||||
for manifest verification and signing. It revalidates the latest source attempt
|
||||
once before publishing; changed or missing proof fails immediately without
|
||||
starting another wait on the publication runner.
|
||||
Image jobs retain
|
||||
their existing package permissions.
|
||||
|
||||
The monitor emits an Actions warning and step summary when an image or source job waits
|
||||
more than 60 seconds for a runner. This measures runner assignment, not image
|
||||
execution. Compare automatic merge-to-ready and merge-to-healthy-target timings;
|
||||
a manual dispatch alone does not prove deployment latency.
|
||||
|
||||
### Typecheck Rust dependency cache
|
||||
|
||||
Source verification's typecheck job builds the native Runner binary through the
|
||||
@@ -236,3 +287,10 @@ fi
|
||||
A subsequent master install can populate the missing entry. Check the saved
|
||||
archive size and package reuse in install logs; a cache hit alone does not prove
|
||||
that the entry contains dependencies.
|
||||
|
||||
Image manifest verification and attestation, plus exact-source migrator build
|
||||
and publication, use the protected four-slot GitHub-hosted `cloud-release-32`
|
||||
pool. `CORE_PUBLICATION_RUNNER=ubuntu-latest` restores standard hosted placement.
|
||||
The signing jobs remain GitHub-hosted because downstream provenance explicitly
|
||||
requires that certificate claim. Their master-only group also serves the private
|
||||
Cloud publisher and harness deployment; it excludes PRs and branch benchmarks.
|
||||
@@ -77,6 +77,12 @@ repeat an existing instance approval.
|
||||
|
||||
## Identity resolution
|
||||
|
||||
Fresh setup does not reuse an archived personal connection found by name and
|
||||
provider. It creates a separate application and connection with an available
|
||||
name, such as `GitHub (2)`. The archived identity, credentials, grants, and
|
||||
history stay unchanged. An explicit reconnect or application-targeted setup
|
||||
continues the existing identity and requires its personal owner.
|
||||
|
||||
Every MCP call, `gh` invocation, native Git operation, checkout, health check,
|
||||
and webhook binding uses the same order:
|
||||
|
||||
|
||||
+10
-2
@@ -1,5 +1,7 @@
|
||||
# Paperclip evaluation guide
|
||||
|
||||
The explicit deployment-targeted [saved agent instructions journey](../tests/runner-e2e/product-journeys/AGENT-SETTINGS.md) checks an owned agent’s ordinary UI save, persisted instruction bytes, one current-run output, and restoration. The task request omits its instruction-only reference. It reuses the existing Product E2E target and report path; local controls do not establish a staging pass.
|
||||
|
||||
The explicit-only [verified API artifact transfer](../tests/runner-e2e/README.md#verified-api-artifact-transfer)
|
||||
cell checks a native Codex agent on Daytona installing an authorized saved API
|
||||
response and delivering unchanged bytes. The oracle binds the source capture,
|
||||
@@ -111,14 +113,20 @@ reply with the late requirement, and persistence after reload. Its attached and
|
||||
managed targets share the same story and permit one steered run or one normal
|
||||
successor. Registration and calibration are separate from a live staging pass.
|
||||
|
||||
The explicit [dependency continuation journey](../tests/runner-e2e/product-journeys/task-dependency-resume.md) checks a finite blocked wait and one automatic native continuation after a browser-completed blocker. Local evidence controls are separate from a deployed QA pass.
|
||||
|
||||
[Reusable Product journeys](../tests/runner-e2e/PRODUCT-JOURNEYS.md) add explicit
|
||||
deployment targets and starting-state recipes. The shared task-properties
|
||||
journey and task-document create/edit/reopen journey use no model; Cloud
|
||||
onboarding supplies reusable verified state. The document journey checks exact
|
||||
saved content and both revision records through independent API reads. Cloud
|
||||
fleet operations and trusted CI remain private. Local eval gateway qualification
|
||||
saved content and both revision records through independent API reads. The explicit `chat-continuity` selection verifies browser reload/reopen, prompt-hidden recall,
|
||||
ordinary `/new` reset, fresh native context and retained history against an attached target.
|
||||
It requires a fresh owned agent with no prior work runs and uses three provider turns plus one synthetic reset row; it does not restart a shared stack.
|
||||
Cloud fleet operations and trusted CI remain private. Local eval gateway qualification
|
||||
does not qualify Cloud routes, and these selections stay outside `--all`.
|
||||
|
||||
The deployment-targeted [saved skill journey](../tests/runner-e2e/product-journeys/saved-skill-use.md) checks one company skill assigned by key, its exact admitted immutable version, a bound native source read/load, and a run-authored saved result from skill-only inputs. It uses the reusable Product journey target and remains unqualified until a real target run passes.
|
||||
|
||||
The explicit-only [case handoff workflow](../tests/runner-e2e/README.md#persistent-case-handoffs-explicit-only)
|
||||
contains arithmetic and receipt smoke cells for a same-task round trip among three native agents using real case/task
|
||||
APIs, saved documents, handoff receipts, and browser readback. Receipt smoke has
|
||||
|
||||
@@ -937,6 +937,13 @@ identity, unknown failures, active leases, and incomplete cleanup still require
|
||||
their normal reconciliation and termination proof. Unknown historical setup errors remain
|
||||
outside this policy, and generic recovery cannot renew its exhausted budget.
|
||||
|
||||
When an admitted continuation reuses a sandbox after verified cleanup, an
|
||||
unpublished preparation uses a directory bound to its writer epoch and lease.
|
||||
Earlier unpublished directories and their receipts stay intact. Repeating the
|
||||
same preparation retains its directory and receipt check. A published workspace
|
||||
on the same allocation retains its recorded directory. Automatic bootstrap
|
||||
timeout retries still require a fresh allocation under the policy above.
|
||||
|
||||
### Runner authentication and ACP startup handshake bounds
|
||||
|
||||
The native Runner waits up to the ten-second connection setup budget for the
|
||||
@@ -1258,6 +1265,18 @@ checks coordinator ownership in its terminal update, so a result recorded after
|
||||
the process check is also protected. Terminal task status remains authoritative;
|
||||
exhausted finalization retries do not keep an otherwise orphaned run alive.
|
||||
|
||||
A native failure that schedules a same-run resume marks the run `failed` but
|
||||
keeps the task's execution and checkout locks for that resume. The sweep leaves
|
||||
those locks in place and clears them only when the task is done or cancelled.
|
||||
If a scheduled resume finds its execution lock free, restart recovery reclaims
|
||||
it, as the local resume claim does, unless another run for the task is queued,
|
||||
running, or scheduled. A lock held by another run still blocks the resume.
|
||||
Another task can hold the same routine execution slot even when this task's
|
||||
lock is free. If that unique claim fails, restart recovery rolls back the whole
|
||||
candidate claim and waits through the existing recovery deadline. It preserves
|
||||
the current routine owner, checkpoint, and provider attempt, and continues the
|
||||
startup batch. Other database failures still fail the recovery pass.
|
||||
|
||||
Automatic productivity reviews are retired. Run counts, missing comments, and elapsed task time do not create review tasks or impose continuation holds. Bounded continuation, provider recovery, budget limits, explicit blockers, and normal review/approval stages remain in force. Existing productivity-review tasks, comments, assignments, and dependencies remain unchanged and readable; their historical origins still identify them as recovery work for recursion suppression.
|
||||
|
||||
### Issue-thread interaction resolution
|
||||
@@ -1834,9 +1853,10 @@ An interrupted conversation does not permanently block its task. For local conve
|
||||
|
||||
Shutdown, process loss, and provider failure use the existing durable failure retry counter and delay. Ordinary failure recovery permits at most two automatic retries in a failure chain. Accepted-interaction infrastructure recovery retains its existing bounded policy. Repeated scheduler visits reuse the same successor; restarting the server does not reset the counter. After exhaustion, automatic attempts stop. A new explicit user message can start a fresh run and failure budget. Productive max-turn continuation and confirmed workspace waits keep their separate existing semantics.
|
||||
|
||||
If shutdown interrupts Paperclip Runner preparation before native execution was
|
||||
selected, recovery verifies that no provider, process launch, native result or
|
||||
coordinator exists. The previous controller must have settled or lost its lease.
|
||||
If shutdown or a recorded unexpected system interruption stops Paperclip Runner
|
||||
preparation before native execution was selected, recovery verifies that no
|
||||
provider, process launch, native result or coordinator exists. The previous
|
||||
controller must have settled or lost its lease.
|
||||
Required workspace recovery must have an exact saved-file receipt and stopped
|
||||
leases before the task can continue. Once these checks pass, the server retires
|
||||
its own unchanged bootstrap-failure block and schedules one bounded retry.
|
||||
@@ -1845,7 +1865,8 @@ restart. A pending Retry or saved message owns its continuation instead of
|
||||
starting a competing automatic attempt. Retry uses the same preparation proof
|
||||
and preserves the recovered workspace. Current task ownership, human blocks,
|
||||
pauses, approvals, budgets and the existing retry limit remain effective.
|
||||
Operator Stops retain their explicit continuation requirements.
|
||||
Operator Stops retain their explicit continuation requirements. Other ambiguous
|
||||
interruptions without a server system-cancellation record remain held.
|
||||
|
||||
Graceful shutdown interrupts an owned legacy adapter through its cancellation
|
||||
control, then waits within one shared 30-second window for provider stop and
|
||||
@@ -2612,6 +2633,26 @@ configuration, and controller-owned credential or instruction resources use
|
||||
verified suspension and saved-history resume. This recovery can restart a
|
||||
provider; it does not suspend every healthy warm turn.
|
||||
|
||||
A different task can reuse the same physical sandbox after its prior task
|
||||
finishes. Provider ingress has one listener per allocation. Before a new logical
|
||||
session starts, Runner retires a locally retained idle session that still owns
|
||||
that listener. The new run must hold the current native claim, task assignment,
|
||||
environment lease and remote workspace writer. Company, agent, provider,
|
||||
allocation, workspace and directory must match. The prior run must be committed
|
||||
and accounted, with its original controller generation and a clean released or
|
||||
expired lease. Retirement saves the prior session through its own checkpoint and
|
||||
file-collection callbacks and waits for close before the new listener binds.
|
||||
After close, admission rechecks the canonical native claim, task, workspace and
|
||||
leases before staging files or invoking the new backend. Ownership loss, Stop
|
||||
or a changed writer during retirement cannot authorize the new execution.
|
||||
The new task keeps its own logical session and authority.
|
||||
|
||||
Active owners, a changed controller or writer, missing canonical proof and failed
|
||||
close block admission. A retained entry stays available for verified retirement;
|
||||
no process is selected by its port number. This rule covers current-controller
|
||||
warm entries with recorded physical identity. A historical process outside that
|
||||
registry still needs the existing exact-owner recovery proof.
|
||||
|
||||
A fresh native session admitted for the current task may replace a terminal
|
||||
predecessor's goal source epoch when the saved goal is absent or complete.
|
||||
It must hold the task's execution lock and assignee. Its accepted terminal
|
||||
@@ -2732,6 +2773,23 @@ replay or access: the saved input and current principal, repository, conversatio
|
||||
runtime fence and execution containment still pass normal admission. Explicit
|
||||
conflicting App bindings remain denials.
|
||||
|
||||
When comments join a queued run with an unclaimed queued wake, admission saves
|
||||
the accepted batch in both the run context and its authoritative primary wake in
|
||||
the same transaction as the coalesced receipt. Claim rebuilds input from that primary wake and filters deleted
|
||||
comments. It does not reconstruct input from historical coalesced receipts.
|
||||
Admission locks and rechecks the run and its same-company, same-agent primary
|
||||
wake. A changed status, mismatched binding, or mismatched durable-receipt actor preserves the incoming
|
||||
input as deferred work instead of consuming it into the old source. Legacy wakes
|
||||
without an authoritative batch retain their existing context-based claim path.
|
||||
An already claimed wake keeps its source unchanged for its existing recovery
|
||||
and replay path; this change does not grant it a new claim.
|
||||
Ordinary mixed-author comments keep their existing coalescing behavior. The
|
||||
primary wake retains its original actor, and each new receipt records its own
|
||||
actual actor. Coalescing updates the queued batch while preserving the original
|
||||
trigger comment fields, so recovery still verifies the original source actor.
|
||||
Same-named agents with different IDs retain separate authority;
|
||||
their new input is deferred without changing the other agent's queued work.
|
||||
|
||||
### Queued input after a native Stop
|
||||
|
||||
A run-only Stop ends the current response. It does not discard queued user
|
||||
|
||||
@@ -210,7 +210,8 @@ diagnostic line on boot and continues without tracing — your server stays up.
|
||||
## Scope
|
||||
|
||||
The OpenTelemetry export carries **traces only**. Metrics and log exporters
|
||||
are out of scope and intentionally not configured here. Auto-instrumentations
|
||||
are out of scope. The SDK receives explicit empty metric-reader and log-processor
|
||||
lists so its default OTLP exporters cannot emit unrequested metrics or logs. Auto-instrumentations
|
||||
for `fs`, `dns`, and `net` are disabled by default because they are too chatty
|
||||
for this workload; everything else from
|
||||
`@opentelemetry/auto-instrumentations-node` is on (HTTP, Express, PG, etc.).
|
||||
|
||||
@@ -102,3 +102,26 @@ Controller leases, in-flight recovery, active writers, and uncertain workspace
|
||||
operations remain blockers. Tests must prove migration leaves failure state
|
||||
intact, rejects stale copyback even if the old folder exists, and preserves
|
||||
recovery source validation and company boundaries.
|
||||
|
||||
## Queued turns during offline enrollment
|
||||
|
||||
The task drain stops new executions. It does not consume queued turns. After
|
||||
Cloud has stopped every application deployment, offline maintenance preserves
|
||||
never-started queued run and wake rows unchanged. Workspace, instruction-file,
|
||||
and historical-log migration use the same execution predicate at this boundary.
|
||||
The replacement uses normal queue admission against the migrated authority.
|
||||
|
||||
Running executions still block migration. A queued row also blocks if it has
|
||||
started, retains process/session/controller evidence, or has a coordinator or
|
||||
environment lease. Task checkout and execution bindings remain separate blockers.
|
||||
Live migration retains its original queued-run exclusion. The offline flag alone
|
||||
is not evidence that old deployments have stopped; Cloud owns that provider fence.
|
||||
|
||||
PostgreSQL/S3 tests cover queued turns and wake payloads surviving migration and
|
||||
loss of the predecessor filesystem, plus queued execution evidence that must
|
||||
remain blocked. The packaged maintenance entry must also retain an unstarted
|
||||
queued turn while it archives historical run output. The assembled heartbeat
|
||||
test resumes that same queued run after deleting the old disk, verifies saved
|
||||
workspace bytes and instructions at provider dispatch, and checks one result and
|
||||
one wake consumption even after a second queue sweep. Its sandbox transport and
|
||||
provider session are scripted; database, S3, admission and restoration are real.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Sleep configuration qualification
|
||||
|
||||
This opt-in harness boots the actual server against an explicitly supplied fresh
|
||||
test database. It measures local SQL, connection and network quiet. A local pass
|
||||
does not establish Railway or Neon suspension. Keep a separate verdict for each
|
||||
layer: quiet, eligibility, database suspension, host suspension, backup persistence,
|
||||
inbound wake, timed wake and return to idle.
|
||||
|
||||
## Local configured services
|
||||
|
||||
Use a reviewed integrated source containing the incoming-wake and domain-deadline
|
||||
contracts. Provision and migrate a fresh dedicated PostgreSQL database ending in
|
||||
`_test` through the normal test operator. The suite does not allocate a database,
|
||||
start embedded PostgreSQL or delete a database. It preserves its fixture rows.
|
||||
Never supply a live tenant database.
|
||||
|
||||
The configured exporter needs the exact optional peer versions already declared
|
||||
in `server/package.json`. With Node 24 and npm available, explicitly install the
|
||||
fixture peers into a new temporary prefix and link only missing packages in this
|
||||
checkout:
|
||||
|
||||
```sh
|
||||
node scripts/sleep-qualification/setup-otel.mjs
|
||||
```
|
||||
|
||||
The setup leaves application manifests and lockfiles unchanged. Retain the printed
|
||||
setup receipt and temporary package lock. A version mismatch fails without
|
||||
replacing an installed package. An opt-in CI job must run the same setup command;
|
||||
setting an OTLP endpoint alone does not qualify an exporter.
|
||||
|
||||
The helper records its exact symlink targets in
|
||||
`server/node_modules/.paperclip-sleep-otel-links.json`. If an owned temporary prefix
|
||||
disappears, rerunning repairs only those matching recorded symlinks. Existing
|
||||
matching packages are left alone. An unrecorded dangling link or other entry is
|
||||
preserved with a recovery error; its pathname is not evidence of ownership.
|
||||
`node scripts/sleep-qualification/setup-otel.mjs --cleanup` unlinks only matching
|
||||
owned links. It preserves foreign replacements and keeps temporary install
|
||||
prefixes for setup evidence. Remove those owned prefixes separately when their
|
||||
receipts are no longer needed.
|
||||
|
||||
Set `PAPERCLIP_SLEEP_CONFIGURED_ACCEPTANCE=1`,
|
||||
`PAPERCLIP_SLEEP_ACCEPTANCE_TEST_DATABASE_URL` to the owned migrated fixture and
|
||||
`PAPERCLIP_SLEEP_CLOUD_SHA` to the exact reviewed Cloud source SHA. Set
|
||||
`PAPERCLIP_SLEEP_CLOUD_CHECKOUT` to that built private checkout; the suite verifies
|
||||
its HEAD and imports its production broker adapter without copying private source.
|
||||
Retain artifacts
|
||||
with `PAPERCLIP_MAINTENANCE_KEEP_ARTIFACTS=1`. Run the two scoped Vitest files with
|
||||
one worker and no file parallelism:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run server/src/__tests__/sleep-otel-export.test.ts \
|
||||
server/src/__tests__/sleep-configuration-acceptance.test.ts \
|
||||
--maxWorkers=1 --no-file-parallelism
|
||||
```
|
||||
|
||||
The first case requires a successful named span at the real local OTLP collector
|
||||
and no metric or log export during 90 real seconds, beyond the SDK's default
|
||||
60-second metric cadence. It fails if the production SDK falls back to no-op.
|
||||
The assembled case keeps
|
||||
normal automatic backups, announcements, first-party telemetry and OTel enabled.
|
||||
It creates an agent, useful completed process execution, API credential and future
|
||||
routine through normal APIs. It waits the real ten-minute authenticated-use
|
||||
cooldown, then measures at least 90 real seconds without tenant HTTP/SQL observer
|
||||
polling. A passive listener on the actual app HTTP server counts incoming requests
|
||||
and WebSocket upgrades, including direct clients outside the harness API helper.
|
||||
It requires the app's existing request and upgrade handlers before installing;
|
||||
it cannot become the sole upgrade listener and change unsupported-upgrade handling.
|
||||
IPC snapshots record its capture identity, installation/validity, cumulative safe-integer
|
||||
counts and observation times. Both snapshots must enclose the complete quiet interval,
|
||||
remain valid and yield an exact zero delta. No URL, headers or body are recorded;
|
||||
snapshot reads themselves make no HTTP requests or SQL queries. Missing or detached
|
||||
capture, malformed counts and the legacy empty `observerRequests` list cannot prove
|
||||
quiet. The passive protocol proxy separately measures database effects. It obtains
|
||||
two owned signed safety reports after the window and preserves fixture history.
|
||||
After ordinary API reactivation it waits another full real ten-minute cooldown,
|
||||
measures another 90 seconds of quiet and requests fresh owned eligibility proof.
|
||||
The helper keeps its legacy defaults for existing maintenance tests.
|
||||
|
||||
Announcements are enabled and the actual route/feed service validates and caches
|
||||
the known fixture. An owned test-child module wrapper injects the existing feed
|
||||
transport seam for the exact default HTTPS URL; unmatched requests fail. Startup
|
||||
fails if the wrapper did not intercept the actual app constructor. The receipt
|
||||
requires one named fetch and no subsequent calls during quiet. Browser mode also
|
||||
requires the fixture copy to render. The production DNS/HTTPS address guard stays
|
||||
unchanged and is not exercised in this combined case; its owning tests cover that
|
||||
boundary separately. Active user activity can legitimately keep an instance awake.
|
||||
|
||||
The assembled scenario includes real tool approval rejection/delivery retirement
|
||||
and signed chat-webhook completion followed by ordinary endpoint removal. That
|
||||
removed-endpoint subcase is separate from the retained enabled GitHub connection:
|
||||
the latter uses actual encrypted user credentials, refresh/continuity owners,
|
||||
signed broker bindings and the production event delivery worker. The host wake
|
||||
capability comes from the production connector store. Local in-memory broker
|
||||
storage and synthetic GitHub HTTP responses remain explicit external boundaries;
|
||||
this does not establish deployed broker durability or host suspension. These
|
||||
assembled paths require a successful run against the reviewed integration before
|
||||
their receipts can qualify them; source presence alone is not evidence.
|
||||
Unknown plugins, persistent provider sessions, unfinished delivery/accounting,
|
||||
uncertain writes and cleanup remain real blockers. Every inventory family needs
|
||||
an explicit disposition before claiming all configurations qualified.
|
||||
|
||||
## Evaluate a receipt
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run server/scripts/sleep-qualification.test.mjs --maxWorkers=1
|
||||
node scripts/sleep-qualification/evidence.mjs receipt.json expected.json report.json
|
||||
```
|
||||
|
||||
`expected.json` binds `appSha`, `cloudSha`, `target` and a SHA-256 `configDigest`.
|
||||
The digest uses `logicalSleepConfiguration(configuration)`: explicit backup interval
|
||||
and checkpoint policy, announcements, telemetry activation, OTLP activation/protocol,
|
||||
and UI activation in a fixed order. It excludes ephemeral loopback ports. Compute
|
||||
the expected logical contract independently before starting the fixture; the
|
||||
evaluator recomputes the receipt digest. Keep actual `collectorOrigins.telemetry`
|
||||
and `.otel` separately, matching the actual configuration and optional expected
|
||||
origin bindings.
|
||||
|
||||
Configured coverage must also have recorded observations. Announcements require
|
||||
actual interception, a request for the expected feed, and no unmatched URL.
|
||||
`signalObservation` records telemetry requests, trace requests, actual OTLP span
|
||||
records and the named qualification span separately. Metric, log, malformed-trace
|
||||
and unexpected request counts must be explicitly zero. The assembled case freezes
|
||||
these observations after each quiet window; feature booleans are derived from
|
||||
them and the observed backup checkpoint. Missing or malformed counts fail local
|
||||
qualification even when a feature flag claims success.
|
||||
|
||||
Before the normal cooldown, a signed diagnostic-only inventory saves both raw
|
||||
reports. It explicitly expects the active API cooldown aggregate and the skipped
|
||||
backup diagnostic; every other reported blocker must be absent. Aggregate local
|
||||
probes cannot identify which probe is present, so this early inventory never
|
||||
proves eligibility. Its hold is released before the full unchanged cooldown.
|
||||
After quiet, raw ordinary reports are saved before assertions; a refusal also saves
|
||||
a diagnostic report under that hold.
|
||||
|
||||
Automatic backup coverage requires a new archive from the normal idle checkpoint,
|
||||
not an enabled flag. After quiet counters freeze, the harness reads every new
|
||||
owned gzip through its trailer, hashes compressed bytes, checks nonempty SQL for
|
||||
the fixture identifier without retaining contents, and observes the wake marker
|
||||
and absence of backup failure markers. This local evidence carries no provider
|
||||
volume or restore claim. The owning backup suite separately qualifies restoration.
|
||||
Archive provenance uses a positive finite opened-file birthtime, falling back to
|
||||
positive finite content mtime only when the filesystem reports birthtime as zero.
|
||||
The selected timestamp and its source are recorded. Existing names, old positive
|
||||
birthtimes, malformed timestamps and future chronology remain disqualifying.
|
||||
Original receipts are retained as produced; the new evidence fields are not
|
||||
retroactively added to earlier runs.
|
||||
The historical Full Y at `487e450b` and short composition at `57744f4e` wrote an
|
||||
unmeasured empty observer-request list. Their recorded SQL, connection, outbound,
|
||||
browser and producer observations remain pinned to those runs, but that list does
|
||||
not meet the corrected inbound-observation contract. A new combined qualification
|
||||
requires both real cooldowns and quiet windows with the installed incoming counter;
|
||||
a short injected-poll regression cannot retroactively establish observer absence.
|
||||
Receipt finalization follows all observations and runs the same evaluator before
|
||||
writing the final receipt. A return-to-idle claim also needs explicit in-run quiet
|
||||
start/end timestamps spanning at least 90 real seconds after the latest wake;
|
||||
a duration field alone cannot prove it.
|
||||
The evaluator rejects mismatched identities and chronology. Missing evidence stays
|
||||
`INCONCLUSIVE`; a measured failed condition is `NOT VERIFIED`, including an
|
||||
identity-verified active Neon endpoint or exact Railway service with running deployments. It does not produce
|
||||
provider proof from local counters, fake time, a registry sleep flag or a successful
|
||||
control response. Preserve original failures and their raw logs after a retry.
|
||||
|
||||
## Deployed boundary
|
||||
|
||||
No deployment target is authorized by this document. The private Cloud companion
|
||||
provides a gated read-only provider observer. An operator must first approve one
|
||||
exact owned disposable staging target, revisions, configuration, provider refs,
|
||||
fixture operations and finite observation deadline. A named connector or shared
|
||||
QA workspace is not that authorization.
|
||||
|
||||
Capture app/Cloud identity and effective policy while awake. Use only provider and
|
||||
central control-plane metadata during sleep; tenant health/API/SQL probes can wake
|
||||
the target. Confirm the exact Neon endpoint is idle and all owned Railway services
|
||||
have stopped deployments. Observe ordinary authorized API, broker and deadline
|
||||
wake cycles separately. Verify useful persisted results and return to idle.
|
||||
Automatic backup persistence additionally requires an exact attached volume
|
||||
covering the configured home/backup directory and a fsynced sentinel/archive
|
||||
continuity receipt across the owned stop/wake. A writable local fallback directory
|
||||
is insufficient. Missing live access leaves these layers `INCONCLUSIVE`.
|
||||
|
||||
For actual foreground/background browser coverage, first build the shipped UI into
|
||||
`server/ui-dist` in the same reviewed checkout. Install the normal Playwright
|
||||
Chromium runtime and run on a display-capable worker. Set
|
||||
`PAPERCLIP_SLEEP_BROWSER_ACCEPTANCE=1` for the configured suite. The harness loads
|
||||
the actual UI, records an ordinary active reload, opens another real foreground
|
||||
tab and requires Chromium to report the app tab hidden. It observes 90 real
|
||||
seconds without app API traffic, then closes the browser before the server quiet
|
||||
window. Failure to establish real visibility fails that mode; it never substitutes
|
||||
a fake visibility event or frozen page. Without this explicit mode, the receipt
|
||||
claims closed-browser coverage only.
|
||||
|
||||
The browser uses a fresh owned Chromium profile and loopback debugging endpoint.
|
||||
It attaches with `connectOverCDP({ noDefaults: true })` to the native default
|
||||
context. Normal Playwright contexts install focus emulation that kept real
|
||||
background tabs visible in the measured failure. This path avoids that override.
|
||||
The receipt includes native window IDs, initial/final visibility and passive
|
||||
visibility events for the entire 90-second interval. An intervening activation
|
||||
fails the observation. Cleanup disconnects, terminates only the owned process and
|
||||
removes its temporary profile. It never attaches to a user's existing browser.
|
||||
|
||||
|
||||
## Managed fixture preflight
|
||||
|
||||
Configured qualification uses the supported managed authenticated profile. The
|
||||
actual app runs on loopback, with a lifetime-stable fixture vault key and the
|
||||
normal managed configuration. A fixture Cloud front door supplies trusted tenant
|
||||
headers to invoke the real primary-company/user/membership provisioning path;
|
||||
ordinary company creation remains forbidden. Bearer-key and signed-control
|
||||
requests omit tenant headers. Native browser headers can reach only the owned
|
||||
app origin; foreign HTTP/WebSocket transports are blocked and fail qualification.
|
||||
|
||||
Managed application storage requires S3. A bounded loopback protocol fixture
|
||||
supplies the remote object service for actual SDK requests; unsupported operations,
|
||||
framing and conditional requests fail closed. This establishes neither provider
|
||||
IAM nor durable S3 storage. The instance home and database archives remain real
|
||||
local filesystem paths. Normal managed migration, historical cleanup and routine
|
||||
owners remain enabled. The logical digest binds managed activation, auth mode and
|
||||
storage provider; actual ephemeral transport origins are recorded separately.
|
||||
|
||||
Before another long campaign, run the opt-in `sleep-managed-preflight.test.ts` on
|
||||
its own root-leased fresh external database using
|
||||
`PAPERCLIP_SLEEP_MANAGED_PREFLIGHT=1` and an explicit dedicated
|
||||
`PAPERCLIP_SLEEP_MANAGED_PREFLIGHT_TEST_DATABASE_URL`. Retain artifacts with
|
||||
`PAPERCLIP_MAINTENANCE_KEEP_ARTIFACTS=1`. It uses the same profile and negotiated
|
||||
broker receiver, provisions real authority, admits and completes one process run,
|
||||
and proves real key activity through signed diagnostics. It performs no browser,
|
||||
ten-minute wait or quiet-window claim. No embedded allocation or automatic drop.
|
||||
Loopback execution requires the host tool's explicit network/runtime permission.
|
||||
|
||||
Setup and successful browser receipts are written immediately before later
|
||||
assertions. Early diagnostics first use the existing owner-settled contract; they
|
||||
reject durable blockers immediately and retry only observed local in-flight /
|
||||
generation inspection incompleteness, up to three rounds. Every attempt is saved.
|
||||
Both accepted reports must be strict, complete, structurally matching and under
|
||||
the exact live hold, with each internal `inspection.generation=none` and no active
|
||||
work before/after. Real HTTP authentication itself changes the global work
|
||||
counter, so equality across deliberate observer requests is not asserted.
|
||||
|
||||
A fresh successful opaque-key request anchors the later full real cooldown.
|
||||
Neither setup stabilization nor the fast preflight substitutes for both real
|
||||
600100ms cooldowns, both untouched 90000ms windows and useful reactivation. S3
|
||||
observations are saved before and after owned app cleanup, including failures;
|
||||
cleanup preserves the original error. Full provider sleep remains a separate
|
||||
unqualified layer until an exact deployed target is authorized and observed.
|
||||
@@ -540,3 +540,26 @@ explicit incomplete usage report before failure; this preserves known budget
|
||||
exposure without settling ambiguous usage. That required report and its receipt
|
||||
retain the accounting measurements and matching saved prior identity, so an
|
||||
optional diagnostic acknowledgement cannot discard reconciliation evidence.
|
||||
|
||||
## Shared allocation ingress retirement
|
||||
|
||||
The `retires the completed child listener` cases in
|
||||
`native-session-executor.test.ts` use the actual executor and warm registry,
|
||||
PostgreSQL ownership rows, and real local TCP listeners. The provider process
|
||||
boundary is supplied by the fixture. The listeners use an ephemeral test port;
|
||||
production provider ingress keeps its fixed port. A completed child's listener
|
||||
must close before a distinct parent session binds in that allocation. The child's
|
||||
accepted result stays unchanged.
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/services/native-runtime/native-session-executor.test.ts \
|
||||
-t 'retires the completed child listener'
|
||||
```
|
||||
|
||||
Controls cover foreign companies, allocations and workspaces; live or unaccounted
|
||||
owners; changed controllers, reassignment and competing leases; workspace-lock
|
||||
and cancellation races; unconfirmed close; and controller, Stop and writer
|
||||
changes inside awaited close. The latter must prevent parent backend invocation
|
||||
even when the old listener has closed. These local checks do not identify
|
||||
a hosted conflicting PID or prove a hosted provider Stop or recovery.
|
||||
@@ -726,6 +726,29 @@ actual shell discovery, successful durable activity recording, and denial of unr
|
||||
Runner state, descriptor writes and ambient secrets. The normal suite covers
|
||||
bounded framing, concurrent publishers, fixed-path cleanup and attachment fencing.
|
||||
|
||||
Codex receives write access to Runner's private activity-channel directory, with
|
||||
the lock file explicitly read-only. The FIFO itself must not be a writable
|
||||
filesystem root: Codex adds protected metadata children to writable roots, which
|
||||
cannot exist beneath a FIFO. The helper and discovery descriptor remain read-only;
|
||||
the activity grant does not expose Runner state or change workspace/network policy.
|
||||
|
||||
To check restrictive Plan mode without credentials or a model call, run the
|
||||
actual channel/policy producer against the pinned Linux Codex sandbox:
|
||||
|
||||
```sh
|
||||
CODEX_NOTIFICATION_SANDBOX_COMMAND=/absolute/path/to/pinned/codex \
|
||||
CODEX_NOTIFICATION_SANDBOX_RESOURCES=/absolute/path/to/pinned/executable/resources \
|
||||
cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --lib \
|
||||
read_only_codex_opens_with_runtime_activity -- --ignored
|
||||
```
|
||||
|
||||
This checks instruction reads, FIFO writes, and denial of workspace writes,
|
||||
unrelated Runner-state reads, descriptor writes, and lock writes/removal. An
|
||||
isolated non-Linux host can instead set `CODEX_NOTIFICATION_FIXTURE_EXPORT` to a
|
||||
JSON file and omit `CODEX_NOTIFICATION_SANDBOX_COMMAND`; that only exports a
|
||||
synthetic producer fixture for a separately controlled Linux sandbox. Exporting
|
||||
the fixture does not execute or verify the sandbox.
|
||||
|
||||
## Direct live protocol qualification
|
||||
|
||||
The canonical direct live protocol suite lives in the separate
|
||||
|
||||
@@ -70,6 +70,35 @@ a scripted harness, removes the controller's launch files, then recovers the
|
||||
same process and provider session and completes another turn. This exercises the
|
||||
real transport; it does not qualify a hosted OpenCode or ACPX deployment.
|
||||
|
||||
## Settled ACPX sessions across a Runner upgrade
|
||||
|
||||
Saved ACPX provider state records the digest of the launch profile that wrote
|
||||
it: the provider-pack authority, the sidecar command and arguments, each launch
|
||||
artifact's SHA-256, and any candidate admission. A deploy that ships a new
|
||||
provider pack or sidecar build changes the digest. The state stores only the
|
||||
digest, so restore cannot tell which part changed.
|
||||
|
||||
Restore re-binds a saved session to the current launch profile, and keeps the
|
||||
conversation, only when all of these are true:
|
||||
|
||||
- The session is suspended. It has no active turn, pending runtime request,
|
||||
undelivered provider event, or unconfirmed provider exit.
|
||||
- The startup has no candidate admission.
|
||||
- The saved descriptor names this startup's sidecar command and arguments.
|
||||
|
||||
Every other binding stays exact. The saved descriptor must match this build's
|
||||
qualified agent profile (ACPX version, agent server package and version, and
|
||||
command digest). Session start verifies each artifact and the restored provider
|
||||
identity. Candidate grants and run bindings keep their own checks.
|
||||
|
||||
Any other launch-profile mismatch, and a saved descriptor that names a release
|
||||
this build does not qualify, fails restore with the closed command-result
|
||||
classification `provider_state_incompatible`. The controller reads the classification from the
|
||||
authenticated result, not from the message. If the run allows a replacement
|
||||
after a failed resume, it opens a fresh provider session as before. Otherwise the
|
||||
run fails with `native_provider_state_incompatible` and is not retried. A board
|
||||
Retry then continues the task in a fresh provider session in the same workspace.
|
||||
|
||||
## Profile identity
|
||||
|
||||
Every ACPX provider's `commandDigest` attests only its own upstream release.
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::durable::QualifiedLaunchArtifact;
|
||||
use crate::durable::{
|
||||
create_private_temporary_file, open_private_regular_file, verify_private_directory,
|
||||
AcpxLaunchProfile, Command, CommandExecution, CommandExecutor, DurableRunnerConfig,
|
||||
DurableRunnerError, EventPriority, PolledEvent,
|
||||
DurableRunnerError, EventPriority, PolledEvent, PROVIDER_STATE_INCOMPATIBLE,
|
||||
};
|
||||
use crate::generated_acpx_sidecar_contract::GeneratedAcpxSidecarCommand;
|
||||
use crate::process_supervisor::{VerifiedProcessArgument, VerifiedProcessLaunch};
|
||||
@@ -279,6 +279,19 @@ fn only_registered_asset_text_changed(
|
||||
}
|
||||
|
||||
impl AcpxProviderDescriptor {
|
||||
// Whether this build qualifies the release that the descriptor names.
|
||||
fn names_qualified_release(&self) -> bool {
|
||||
acpx_release_profile(&self.agent).is_some_and(|expected| {
|
||||
self.provider_version == QUALIFIED_ACPX_VERSION
|
||||
&& self.acpx_version == QUALIFIED_ACPX_VERSION
|
||||
&& self.agent_server_package == expected.agent_server_package
|
||||
&& self.agent_server_version == expected.agent_server_version
|
||||
&& self.agent_runtime_package.as_deref() == expected.agent_runtime_package
|
||||
&& self.agent_runtime_version.as_deref() == expected.agent_runtime_version
|
||||
&& self.command_digest == expected.command_digest
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_session(
|
||||
&self,
|
||||
context: &AcpxEventProjectionContext,
|
||||
@@ -622,11 +635,21 @@ impl AcpxDurableState {
|
||||
// so its persisted descriptor legitimately carries the prior run ID
|
||||
// until run.attach rotates authority. Fresh command descriptors still
|
||||
// use validate(), which binds them to the current run.
|
||||
self.descriptor.validate_session(context)?;
|
||||
// A deploy that changes the agent's qualified release also leaves
|
||||
// saved state that this build cannot restore. Keep the same check and
|
||||
// only name its class.
|
||||
self.descriptor.validate_session(context).map_err(|error| {
|
||||
if self.descriptor.names_qualified_release() {
|
||||
error
|
||||
} else {
|
||||
error.with_classification(PROVIDER_STATE_INCOMPATIBLE)
|
||||
}
|
||||
})?;
|
||||
if self.launch_profile_digest != expected_launch_profile_digest {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX durable launch profile digest does not match runner startup",
|
||||
));
|
||||
)
|
||||
.with_classification(PROVIDER_STATE_INCOMPATIBLE));
|
||||
}
|
||||
validate_pending_runtime_requests(&self.pending_runtime_requests)?;
|
||||
let mut ids = HashSet::new();
|
||||
@@ -960,6 +983,34 @@ impl AcpxCommandExecutor {
|
||||
prior.canonical_digest()
|
||||
}
|
||||
|
||||
// A deploy changes the provider-pack authority and the sidecar and Node
|
||||
// artifact digests, so a saved digest from the previous build never
|
||||
// matches. The digest is a hash, so restore cannot tell which part
|
||||
// changed. Re-bind only when no provider work is in flight:
|
||||
// - The session is suspended. No turn, request, undelivered event, or
|
||||
// unconfirmed provider exit remains. Nothing from the old provider
|
||||
// process continues; the next launch only loads the conversation.
|
||||
// - The startup is ordinary. Candidate admission carries company, model
|
||||
// and expiry authority in the digest, so candidate sessions stay
|
||||
// exactly bound.
|
||||
// - The saved descriptor names this startup's sidecar command and
|
||||
// arguments. Only the qualified content at those paths changed.
|
||||
// validate() still binds the descriptor to this build's qualified agent
|
||||
// profile (ACPX, agent server and command digest), and session start
|
||||
// still verifies every artifact and the restored provider identity.
|
||||
fn rebinds_settled_session(&self, state: &AcpxDurableState) -> bool {
|
||||
self.launch_profile.as_ref().is_some_and(|profile| {
|
||||
profile.candidate_admission.is_none()
|
||||
&& state.descriptor.sidecar_command == profile.command
|
||||
&& state.descriptor.sidecar_args == profile.args
|
||||
}) && state.lifecycle == "suspended"
|
||||
&& state.identity.is_some()
|
||||
&& state.active_turn_id.is_none()
|
||||
&& !state.provider_exit_unconfirmed
|
||||
&& state.pending_events.is_empty()
|
||||
&& state.pending_runtime_requests.is_empty()
|
||||
}
|
||||
|
||||
fn restore(&mut self) -> Result<(), DurableRunnerError> {
|
||||
self.ensure_persistence_healthy()?;
|
||||
if self.restore_checked {
|
||||
@@ -1016,10 +1067,15 @@ impl AcpxCommandExecutor {
|
||||
file.read_to_end(&mut bytes).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("failed to read ACPX provider state: {error}"))
|
||||
})?;
|
||||
let state: AcpxDurableState = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
let mut state: AcpxDurableState = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("ACPX provider state is malformed: {error}"))
|
||||
})?;
|
||||
let launch_profile_digest = self.restore_launch_profile_digest(&state)?;
|
||||
if state.launch_profile_digest != launch_profile_digest
|
||||
&& self.rebinds_settled_session(&state)
|
||||
{
|
||||
state.launch_profile_digest = launch_profile_digest.clone();
|
||||
}
|
||||
state.validate(&self.context, &launch_profile_digest)?;
|
||||
self.state = Some(state);
|
||||
self.restore_session_if_needed()
|
||||
@@ -5298,14 +5354,11 @@ mod tests {
|
||||
"grant_run",
|
||||
"injected_candidate",
|
||||
"context_run",
|
||||
"authority",
|
||||
"command",
|
||||
"args",
|
||||
"artifact",
|
||||
"missing_profile",
|
||||
"saved_digest",
|
||||
] {
|
||||
let (directory, mut config, payload, before, sentinel, candidate) =
|
||||
let (directory, mut config, payload, _, sentinel, candidate) =
|
||||
kimi_cold_restore_fixture("kimi-released-cold-reject");
|
||||
assert!(config
|
||||
.acpx_launch_profile
|
||||
@@ -5316,26 +5369,11 @@ mod tests {
|
||||
match field {
|
||||
"context_run" => config.run_id = "foreign-run".into(),
|
||||
"missing_profile" => config.acpx_launch_profile = None,
|
||||
"saved_digest" => {
|
||||
let mut state: AcpxDurableState = serde_json::from_slice(&before).unwrap();
|
||||
state.launch_profile_digest = format!("sha256:{}", "0".repeat(64));
|
||||
fs::write(
|
||||
directory.join("acpx-provider-state.json"),
|
||||
serde_json::to_vec(&state).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
_ => {
|
||||
let profile = config.acpx_launch_profile.as_mut().unwrap();
|
||||
match field {
|
||||
"authority" => {
|
||||
profile.authority_digest = format!("sha256:{}", "0".repeat(64))
|
||||
}
|
||||
"command" => profile.command = directory.join("foreign-command"),
|
||||
"args" => profile.args.push("foreign-argument".into()),
|
||||
"artifact" => {
|
||||
profile.artifacts[0].sha256 = format!("sha256:{}", "0".repeat(64))
|
||||
}
|
||||
_ => {
|
||||
let mut grant = candidate;
|
||||
match field {
|
||||
@@ -5382,6 +5420,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64")
|
||||
))]
|
||||
fn released_kimi_cold_restore_rebinds_a_settled_session_to_new_launch_content() {
|
||||
// An ordinary startup re-binds a settled session after a provider-pack
|
||||
// or artifact change at the same launch paths. The checks above for
|
||||
// candidate grants, command, arguments and run binding still apply.
|
||||
for field in ["authority", "artifact", "saved_digest"] {
|
||||
let (directory, mut config, payload, before, sentinel, _) =
|
||||
kimi_cold_restore_fixture("kimi-released-cold-rebind");
|
||||
let profile = config.acpx_launch_profile.as_mut().unwrap();
|
||||
match field {
|
||||
"authority" => profile.authority_digest = format!("sha256:{}", "0".repeat(64)),
|
||||
"artifact" => {
|
||||
let command = profile.command.clone();
|
||||
write_artifact(&command, b"#!/bin/sh\nexit 2\n", true);
|
||||
profile.artifacts[0] = artifact(&command);
|
||||
}
|
||||
"saved_digest" => {
|
||||
let mut state: AcpxDurableState = serde_json::from_slice(&before).unwrap();
|
||||
state.launch_profile_digest = format!("sha256:{}", "0".repeat(64));
|
||||
fs::write(
|
||||
directory.join(ACPX_PROVIDER_STATE_FILE),
|
||||
serde_json::to_vec(&state).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
config.validate().unwrap();
|
||||
let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
recovered.restore().expect(field);
|
||||
recovered.attach_run(&payload).expect(field);
|
||||
let saved: AcpxDurableState =
|
||||
serde_json::from_slice(&fs::read(recovered.state_path()).unwrap()).unwrap();
|
||||
assert_eq!(saved.descriptor.run_id, "run-2", "{field}");
|
||||
assert_eq!(
|
||||
saved.launch_profile_digest,
|
||||
config
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_digest()
|
||||
.unwrap(),
|
||||
"{field}"
|
||||
);
|
||||
assert!(recovered.session.is_none(), "{field}");
|
||||
assert!(!sentinel.exists(), "{field}");
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
@@ -5498,4 +5590,217 @@ mod tests {
|
||||
assert!(!sentinel.exists());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
// A settled Claude session saved by one runner build. The next deploy
|
||||
// ships a new sidecar build and provider-pack authority at the same
|
||||
// qualified launch paths, and the next run cold-attaches in a new runner.
|
||||
#[cfg(unix)]
|
||||
fn settled_session_after_runner_upgrade(
|
||||
label: &str,
|
||||
) -> (PathBuf, DurableRunnerConfig, Value, Vec<u8>) {
|
||||
let directory = temporary_directory(label);
|
||||
let command = directory.join("node");
|
||||
let sidecar = directory.join("acpx-runtime-sidecar.cjs");
|
||||
write_artifact(&command, b"#!/bin/sh\nexit 1\n", true);
|
||||
write_artifact(&sidecar, b"// sidecar build 1\n", false);
|
||||
let profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: vec![sidecar.to_string_lossy().into_owned()],
|
||||
artifacts: vec![artifact(&command), artifact(&sidecar)],
|
||||
};
|
||||
let runtime = directory.join("runtime");
|
||||
let workspace = directory.join("workspace");
|
||||
fs::create_dir_all(&runtime).unwrap();
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(&workspace, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let mut value = descriptor("claude");
|
||||
value["runtimeDirectory"] = json!(runtime);
|
||||
value["cwd"] = json!(workspace);
|
||||
value["sidecarCommand"] = json!(command);
|
||||
value["sidecarArgs"] = json!([sidecar]);
|
||||
let desc: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
|
||||
let identity = AcpxProviderSessionIdentity {
|
||||
kind: "acpx".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
acpx_record_id: "record-1".to_owned(),
|
||||
backend_session_id: "backend-1".to_owned(),
|
||||
agent_session_id: "agent-1".to_owned(),
|
||||
profile_digest: desc.command_digest.clone(),
|
||||
workspace_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
requested_model: desc.model.clone(),
|
||||
effective_model: desc.model.clone(),
|
||||
permission_mode: Some(desc.permission_mode),
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
grok_effort: None,
|
||||
grok_disable_web_search: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
let tools = AuthorizedToolSet {
|
||||
schema: TOOL_SET_SCHEMA.to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
};
|
||||
let mut state = AcpxDurableState::new(desc, tools, profile.canonical_digest().unwrap());
|
||||
state.lifecycle = "suspended".to_owned();
|
||||
state.identity = Some(identity);
|
||||
let config = test_config(&directory, Some(profile));
|
||||
let mut executor = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
executor.state = Some(state);
|
||||
executor.save_state().unwrap();
|
||||
let saved = fs::read(executor.state_path()).unwrap();
|
||||
|
||||
write_artifact(&sidecar, b"// sidecar build 2\n", false);
|
||||
let mut next_config = config;
|
||||
next_config.run_id = "run-2".to_owned();
|
||||
next_config.turn_id = "turn-2".to_owned();
|
||||
next_config.item_id = "item-2".to_owned();
|
||||
let next_profile = next_config.acpx_launch_profile.as_mut().unwrap();
|
||||
next_profile.authority_digest = format!("sha256:{}", "e".repeat(64));
|
||||
next_profile.artifacts = vec![artifact(&command), artifact(&sidecar)];
|
||||
next_config.validate().unwrap();
|
||||
let mut provider = value;
|
||||
provider["runId"] = json!("run-2");
|
||||
(
|
||||
directory,
|
||||
next_config,
|
||||
json!({ "provider": provider }),
|
||||
saved,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn settled_session_rebinds_to_the_upgraded_runner_launch_profile() {
|
||||
let (directory, config, payload, saved) =
|
||||
settled_session_after_runner_upgrade("upgrade-rebind");
|
||||
let prior: AcpxDurableState = serde_json::from_slice(&saved).unwrap();
|
||||
let current_digest = config
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_digest()
|
||||
.unwrap();
|
||||
assert_ne!(prior.launch_profile_digest, current_digest);
|
||||
|
||||
let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
recovered
|
||||
.restore()
|
||||
.expect("a settled session survives a new qualified sidecar build");
|
||||
assert!(recovered.session.is_none());
|
||||
assert!(recovered.poll_events().unwrap().is_empty());
|
||||
assert_eq!(fs::read(recovered.state_path()).unwrap(), saved);
|
||||
recovered
|
||||
.attach_run(&payload)
|
||||
.expect("the next run cold-attaches the same conversation");
|
||||
assert!(recovered.session.is_none());
|
||||
|
||||
let attached: AcpxDurableState =
|
||||
serde_json::from_slice(&fs::read(recovered.state_path()).unwrap()).unwrap();
|
||||
assert_eq!(attached.launch_profile_digest, current_digest);
|
||||
assert_eq!(attached.descriptor.run_id, "run-2");
|
||||
assert_eq!(attached.lifecycle, "suspended");
|
||||
assert_eq!(attached.identity, prior.identity);
|
||||
assert_eq!(
|
||||
attached.descriptor.command_digest,
|
||||
prior.descriptor.command_digest
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unrestorable_saved_sessions_keep_strict_bindings_and_name_their_class() {
|
||||
for field in [
|
||||
"active_turn",
|
||||
"pending_request",
|
||||
"pending_event",
|
||||
"session_open",
|
||||
"exit_unconfirmed",
|
||||
"no_identity",
|
||||
"command_moved",
|
||||
"args_changed",
|
||||
"agent_server_release",
|
||||
"acpx_release",
|
||||
] {
|
||||
let (directory, mut config, _, saved) =
|
||||
settled_session_after_runner_upgrade("upgrade-strict");
|
||||
let state_path = directory.join(ACPX_PROVIDER_STATE_FILE);
|
||||
let mut state: AcpxDurableState = serde_json::from_slice(&saved).unwrap();
|
||||
let profile = config.acpx_launch_profile.as_mut().unwrap();
|
||||
match field {
|
||||
"active_turn" => {
|
||||
state.lifecycle = "turn_active".into();
|
||||
state.active_turn_id = Some("turn-1".into());
|
||||
}
|
||||
"pending_request" => {
|
||||
state.lifecycle = "turn_active".into();
|
||||
state.active_turn_id = Some("turn-1".into());
|
||||
state
|
||||
.push(NormalizedProviderEvent {
|
||||
event_type: "runtime_request.created".into(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({"request":pending_input_request("input-1")}),
|
||||
})
|
||||
.unwrap();
|
||||
state.pending_events.clear();
|
||||
}
|
||||
"pending_event" => state
|
||||
.push(NormalizedProviderEvent {
|
||||
event_type: "session.updated".into(),
|
||||
priority: EventPriority::P1,
|
||||
payload: json!({}),
|
||||
})
|
||||
.unwrap(),
|
||||
"session_open" => state.lifecycle = "session_open".into(),
|
||||
"exit_unconfirmed" => {
|
||||
state.lifecycle = "closed".into();
|
||||
state.provider_exit_unconfirmed = true;
|
||||
}
|
||||
"no_identity" => {
|
||||
state.lifecycle = "prepared".into();
|
||||
state.identity = None;
|
||||
}
|
||||
"command_moved" => {
|
||||
let moved = directory.join("relocated-node");
|
||||
write_artifact(&moved, b"#!/bin/sh\nexit 1\n", true);
|
||||
profile.command = moved.clone();
|
||||
profile.artifacts[0] = artifact(&moved);
|
||||
}
|
||||
"args_changed" => profile.args.push("--changed".into()),
|
||||
"agent_server_release" => state.descriptor.agent_server_version = "0.0.1".into(),
|
||||
"acpx_release" => {
|
||||
state.descriptor.acpx_version = "0.0.1".into();
|
||||
state.descriptor.provider_version = "0.0.1".into();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
config.validate().unwrap();
|
||||
fs::write(&state_path, serde_json::to_vec(&state).unwrap()).unwrap();
|
||||
let retained = fs::read(&state_path).unwrap();
|
||||
|
||||
let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
let error = recovered.restore().unwrap_err();
|
||||
let expected = if field.ends_with("_release") {
|
||||
"does not match a qualified immutable profile"
|
||||
} else {
|
||||
"launch profile digest does not match runner startup"
|
||||
};
|
||||
assert!(error.to_string().contains(expected), "{field}: {error}");
|
||||
assert_eq!(
|
||||
error.classification(),
|
||||
Some(crate::durable::PROVIDER_STATE_INCOMPATIBLE),
|
||||
"{field}"
|
||||
);
|
||||
assert!(recovered.session.is_none(), "{field}");
|
||||
assert_eq!(fs::read(&state_path).unwrap(), retained, "{field}");
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,13 @@ pub(crate) fn runtime_notification_codex_args(
|
||||
let Some(filesystem) = config["filesystem"].as_inline_table_mut() else {
|
||||
continue;
|
||||
};
|
||||
for (path, mode) in paths.iter().zip(["read", "read", "read", "write"]) {
|
||||
filesystem.insert(path.to_string_lossy().as_ref(), mode.into());
|
||||
let Some(channel_directory) = paths[3].parent().filter(|path| path.parent().is_some())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
filesystem.insert(channel_directory.to_string_lossy().as_ref(), "write".into());
|
||||
for path in &paths[..3] {
|
||||
filesystem.insert(path.to_string_lossy().as_ref(), "read".into());
|
||||
}
|
||||
extra.extend(["-c".into(), format!("{prefix}{filesystem}")]);
|
||||
}
|
||||
@@ -4522,8 +4527,8 @@ mod tests {
|
||||
let paths = [
|
||||
PathBuf::from("/runtime/runnerd"),
|
||||
PathBuf::from("/state/private/runtime-notifications.json"),
|
||||
PathBuf::from("/state/private/runtime-activity.lock"),
|
||||
PathBuf::from("/tmp/prn-channel"),
|
||||
PathBuf::from("/tmp/prn-channel/activity.lock"),
|
||||
PathBuf::from("/tmp/prn-channel/activity.fifo"),
|
||||
];
|
||||
let extra =
|
||||
runtime_notification_codex_args(&args, "paperclip-runner-workspace-only", &paths);
|
||||
@@ -4562,7 +4567,7 @@ mod tests {
|
||||
("/state/private", "none"),
|
||||
("/runtime/runnerd", "read"),
|
||||
("/state/private/runtime-notifications.json", "read"),
|
||||
("/state/private/runtime-activity.lock", "read"),
|
||||
("/tmp/prn-channel/activity.lock", "read"),
|
||||
("/tmp/prn-channel", "write"),
|
||||
] {
|
||||
assert_eq!(
|
||||
@@ -4570,6 +4575,7 @@ mod tests {
|
||||
Some(mode)
|
||||
);
|
||||
}
|
||||
assert!(filesystem.get("/tmp/prn-channel/activity.fifo").is_none());
|
||||
}
|
||||
assert!(!serialized.contains("network."));
|
||||
assert!(!serialized.contains("PAPERCLIP_RUN_ID"));
|
||||
|
||||
@@ -37,12 +37,17 @@ pub fn redact_diagnostic_text(input: &str) -> String {
|
||||
state::redact_text(input)
|
||||
}
|
||||
|
||||
/// Saved provider state was written under a launch or provider profile that
|
||||
/// this runner cannot adopt. The outcome is deterministic for this runner
|
||||
/// build: retrying the same restore cannot succeed.
|
||||
pub const PROVIDER_STATE_INCOMPATIBLE: &str = "provider_state_incompatible";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DurableRunnerError(String, Option<serde_json::Value>);
|
||||
pub struct DurableRunnerError(String, Option<serde_json::Value>, Option<&'static str>);
|
||||
|
||||
impl DurableRunnerError {
|
||||
pub fn invalid(message: impl Into<String>) -> Self {
|
||||
Self(message.into(), None)
|
||||
Self(message.into(), None, None)
|
||||
}
|
||||
|
||||
pub fn with_provider_failure(mut self, value: Option<serde_json::Value>) -> Self {
|
||||
@@ -53,6 +58,17 @@ impl DurableRunnerError {
|
||||
pub fn provider_failure(&self) -> Option<serde_json::Value> {
|
||||
self.1.clone()
|
||||
}
|
||||
|
||||
/// Adds a closed failure class that the controller reads from the command
|
||||
/// result instead of parsing the message.
|
||||
pub fn with_classification(mut self, classification: &'static str) -> Self {
|
||||
self.2 = Some(classification);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn classification(&self) -> Option<&'static str> {
|
||||
self.2
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for DurableRunnerError {
|
||||
|
||||
@@ -1930,6 +1930,9 @@ fn process_command<E: CommandExecutor>(
|
||||
"code": "command_execution_failed",
|
||||
"message": message,
|
||||
});
|
||||
if let Some(classification) = error.classification() {
|
||||
failure["classification"] = json!(classification);
|
||||
}
|
||||
if let Some(provider_failure) = error.provider_failure() {
|
||||
failure["providerFailure"] = provider_failure;
|
||||
}
|
||||
@@ -2489,6 +2492,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct IncompatibleStateExecutor;
|
||||
|
||||
impl CommandExecutor for IncompatibleStateExecutor {
|
||||
fn execute(&mut self, _command: &Command) -> Result<CommandExecution, DurableRunnerError> {
|
||||
Err(
|
||||
DurableRunnerError::invalid("saved provider state cannot be adopted")
|
||||
.with_classification(crate::durable::PROVIDER_STATE_INCOMPATIBLE),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandExecutor for ShutdownFailingExecutor {
|
||||
fn execute(&mut self, _command: &Command) -> Result<CommandExecution, DurableRunnerError> {
|
||||
Ok(CommandExecution::result(json!({"status": "completed"})))
|
||||
@@ -4865,6 +4879,46 @@ mod tests {
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| !diagnostic.contains("test-secret")));
|
||||
assert!(failed.result.get("classification").is_none());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classified_executor_failure_keeps_its_closed_class_in_the_durable_result() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"paperclip-runner-command-classified-failure-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&directory);
|
||||
let config = config(directory.clone());
|
||||
let store = DurableStateStore::new(&directory).unwrap();
|
||||
let (mut state, _) = store.load_or_create(&config).unwrap();
|
||||
let command = command("run.attach");
|
||||
let (failed, _) = process_command(
|
||||
&mut state,
|
||||
&store,
|
||||
&config,
|
||||
&mut IncompatibleStateExecutor,
|
||||
&command,
|
||||
)
|
||||
.unwrap();
|
||||
let (mut recovered, _) = store.load_or_create(&config).unwrap();
|
||||
let replay = process_command(
|
||||
&mut recovered,
|
||||
&store,
|
||||
&config,
|
||||
&mut IncompatibleStateExecutor,
|
||||
&command,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(failed.status, "failed");
|
||||
assert_eq!(failed.result["code"], "command_execution_failed");
|
||||
assert_eq!(
|
||||
failed.result["classification"],
|
||||
"provider_state_incompatible"
|
||||
);
|
||||
assert_eq!(failed, replay);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -666,6 +666,8 @@ mod tests {
|
||||
assert_eq!(fs::read(nested.join("protected")).unwrap(), b"unchanged");
|
||||
fs::remove_dir_all(pipe.parent().unwrap()).unwrap();
|
||||
}
|
||||
include!("runtime_notifications/tests/codex_sandbox.rs");
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires CODEX_NOTIFICATION_SANDBOX_COMMAND and PAPERCLIP_RUNTIME_NOTIFICATION_HELPER"]
|
||||
fn publisher_crosses_real_codex_tool_boundary_without_runner_state_access() {
|
||||
@@ -709,7 +711,8 @@ mod tests {
|
||||
if cat "$1" >/dev/null 2>&1; then exit 71; fi
|
||||
if /bin/sh -c ': >> "$PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT"' 2>/dev/null; then exit 72; fi
|
||||
if touch "$2/probe" 2>/dev/null; then exit 75; fi
|
||||
if touch "$3/probe" 2>/dev/null; then exit 76; fi
|
||||
touch "$3/probe" || exit 76
|
||||
rm "$3/probe" || exit 76
|
||||
if /bin/sh -c ': >> "$1"' ignored "$4" 2>/dev/null; then exit 77; fi
|
||||
context=$("$PAPERCLIP_RUNTIME_NOTIFICATION_HELPER" notification-context) || exit 73
|
||||
"$PAPERCLIP_RUNTIME_NOTIFICATION_HELPER" record-activity "$context" example.runtime desktop.used || exit 74
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
#[test]
|
||||
fn codex_activity_grant_uses_only_its_own_private_channel_directory() {
|
||||
let (_directory, config, _store, _state, server) = setup();
|
||||
let (_other_directory, _other_config, _other_store, _other_state, other) = setup();
|
||||
let channel = server.pipe.0.parent().unwrap();
|
||||
let other_channel = other.pipe.0.parent().unwrap();
|
||||
assert_ne!(channel, other_channel);
|
||||
verify_private_directory(channel).unwrap();
|
||||
assert!(fs::symlink_metadata(&server.pipe.0)
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_fifo());
|
||||
let paths = [
|
||||
PathBuf::from("/runtime/runnerd"),
|
||||
context_path(&config.state_dir),
|
||||
server.pipe.0.with_extension("lock"),
|
||||
server.pipe.0.clone(),
|
||||
];
|
||||
let profile = "paperclip-runner-workspace-read-only";
|
||||
let args = vec![
|
||||
"shell_environment_policy.include_only=[]".into(),
|
||||
format!("permissions.{profile}.filesystem={{\":root\"=\"none\"}}"),
|
||||
];
|
||||
let projected = crate::codex_provider::runtime_notification_codex_args(&args, profile, &paths);
|
||||
let prefix = format!("permissions.{profile}.filesystem=");
|
||||
let value = projected
|
||||
.iter()
|
||||
.find_map(|arg| arg.strip_prefix(&prefix))
|
||||
.unwrap();
|
||||
let config = format!("filesystem={value}")
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
let filesystem = config["filesystem"].as_inline_table().unwrap();
|
||||
assert_eq!(
|
||||
filesystem
|
||||
.get(channel.to_str().unwrap())
|
||||
.and_then(toml_edit::Value::as_str),
|
||||
Some("write")
|
||||
);
|
||||
assert!(filesystem.get(other_channel.to_str().unwrap()).is_none());
|
||||
assert!(filesystem
|
||||
.get(paths[1].parent().unwrap().to_str().unwrap())
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires source-pinned Linux CODEX_NOTIFICATION_SANDBOX_COMMAND or CODEX_NOTIFICATION_FIXTURE_EXPORT"]
|
||||
fn read_only_codex_opens_with_runtime_activity_without_widening_file_access() {
|
||||
use std::process::Command;
|
||||
|
||||
let directory = TestDirectory::create(&std::env::temp_dir()).unwrap();
|
||||
let config = config(directory.directory.join("state"));
|
||||
fs::DirBuilder::new()
|
||||
.mode(0o700)
|
||||
.create(&config.state_dir)
|
||||
.unwrap();
|
||||
let server = RuntimeNotifications::bind(&config.state_dir).unwrap();
|
||||
let workspace = directory.directory.join("workspace");
|
||||
let home = directory.directory.join("home");
|
||||
fs::create_dir(&workspace).unwrap();
|
||||
fs::create_dir(&home).unwrap();
|
||||
fs::write(
|
||||
home.join("credential-fixture"),
|
||||
b"synthetic private credential",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
workspace.join("AGENTS.md"),
|
||||
b"Read-only fixture instructions.\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
config.state_dir.join("private-state"),
|
||||
b"must remain inaccessible",
|
||||
)
|
||||
.unwrap();
|
||||
let helper = config.state_dir.join("helper");
|
||||
fs::write(&helper, b"synthetic executable resource\n").unwrap();
|
||||
let paths = [
|
||||
helper,
|
||||
context_path(&config.state_dir),
|
||||
server.pipe.0.with_extension("lock"),
|
||||
server.pipe.0.clone(),
|
||||
];
|
||||
let profile = "paperclip-runner-workspace-read-only";
|
||||
let resources = std::env::var("CODEX_NOTIFICATION_SANDBOX_RESOURCES")
|
||||
.expect("set the source-pinned Codex executable resource directory");
|
||||
let resources = serde_json::to_string(&resources).unwrap();
|
||||
let args = vec![
|
||||
"-c".into(),
|
||||
"shell_environment_policy.include_only=[\"PATH\"]".into(),
|
||||
"-c".into(),
|
||||
format!("permissions.{profile}.filesystem={{\":root\"=\"none\",\":minimal\"=\"read\",\":tmpdir\"=\"none\",{resources}=\"read\",\"{}\"=\"none\",\"{}\"=\"read\"}}", home.display(), workspace.display()),
|
||||
"-c".into(),
|
||||
format!("permissions.{profile}.network.enabled=false"),
|
||||
];
|
||||
let extra = crate::codex_provider::runtime_notification_codex_args(&args, profile, &paths);
|
||||
let script = r#"
|
||||
set -eu
|
||||
test -z "${UNRELATED_SECRET:-}"
|
||||
test "$(cat "$4/AGENTS.md")" = 'Read-only fixture instructions.'
|
||||
if touch "$4/forbidden" 2>/dev/null; then exit 71; fi
|
||||
if cat "$1/private-state" >/dev/null 2>&1; then exit 72; fi
|
||||
if sh -c ': >> "$1"' ignored "$2" 2>/dev/null; then exit 73; fi
|
||||
if sh -c ': >> "$1"' ignored "$3" 2>/dev/null; then exit 74; fi
|
||||
if rm "$3" 2>/dev/null; then exit 75; fi
|
||||
if cat "$6/credential-fixture" >/dev/null 2>&1; then exit 76; fi
|
||||
cat "$2" >/dev/null
|
||||
cat "$3" >/dev/null
|
||||
printf 'synthetic notification frame\n' > "$5"
|
||||
"#;
|
||||
let mut args = args;
|
||||
args.extend(extra);
|
||||
args.extend(["sandbox", "-P", profile, "-C"].map(str::to_owned));
|
||||
args.push(workspace.to_string_lossy().into_owned());
|
||||
args.extend(["--", "/bin/sh", "-c", script, "notification-sandbox"].map(str::to_owned));
|
||||
args.extend(
|
||||
[
|
||||
&config.state_dir,
|
||||
&paths[1],
|
||||
&paths[2],
|
||||
&workspace,
|
||||
&paths[3],
|
||||
&home,
|
||||
]
|
||||
.map(|path| path.to_string_lossy().into_owned()),
|
||||
);
|
||||
if let Some(path) = std::env::var_os("CODEX_NOTIFICATION_FIXTURE_EXPORT") {
|
||||
fs::write(
|
||||
path,
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"args": args, "stateDirectory": config.state_dir,
|
||||
"pipe": server.pipe.0, "home": home, "workspace": workspace,
|
||||
"descriptor": fs::read_to_string(&paths[1]).unwrap(),
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
let command = std::env::var_os("CODEX_NOTIFICATION_SANDBOX_COMMAND").unwrap();
|
||||
let output = Command::new(command)
|
||||
.args(args)
|
||||
.current_dir(&workspace)
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var_os("PATH").unwrap())
|
||||
.env("HOME", &home)
|
||||
.env("CODEX_HOME", &home)
|
||||
.env("UNRELATED_SECRET", "must-not-reach-command")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"source-pinned sandbox failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BaseSequencer } from "vitest/node";
|
||||
|
||||
const slowFiles = [
|
||||
"runnerd-codex-transport.test.ts",
|
||||
"runnerd-codex-transport-settlement.test.ts",
|
||||
"cursor-cloud-runner-launch.test.ts",
|
||||
"runnerd-codex-transport-deadlines.test.ts",
|
||||
"opencode-server-driver.test.ts",
|
||||
"acpx-controller-recovery.test.ts",
|
||||
"runnerd-codex-transport-warm-attachment.test.ts",
|
||||
"runnerd-opencode-streaming-tools.test.ts",
|
||||
"live-session.test.ts",
|
||||
"runnerd-codex-transport-adoption.test.ts",
|
||||
];
|
||||
|
||||
const priority = (file) => {
|
||||
const name = file.moduleId.replaceAll("\\", "/").split("/").at(-1);
|
||||
const index = slowFiles.indexOf(name);
|
||||
return index < 0 ? slowFiles.length : index;
|
||||
};
|
||||
|
||||
export class RunnerSequencer extends BaseSequencer {
|
||||
async sort(files) {
|
||||
const sorted = await super.sort(files);
|
||||
return sorted.sort((a, b) => priority(a) - priority(b));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import { BaseSequencer } from "vitest/node";
|
||||
import { RunnerSequencer } from "./vitest-sequencer.mjs";
|
||||
|
||||
const project = { name: "runner", config: { isolate: true, sequence: { groupOrder: 0 } } };
|
||||
const files = [
|
||||
"large.test.ts",
|
||||
"runnerd-codex-transport.test.ts",
|
||||
"small.test.ts",
|
||||
"runnerd-codex-transport-settlement.test.ts",
|
||||
"runnerd-codex-transport-deadlines.test.ts",
|
||||
].map((name) => ({ moduleId: `/repo/src/live/${name}`, project }));
|
||||
const ctx = {
|
||||
config: { root: "/repo", shard: { index: 1, count: 2 } },
|
||||
cache: {
|
||||
getFileTestResults: () => undefined,
|
||||
getFileStats: (key) => ({ size: 100 - files.findIndex((file) => key.endsWith(file.moduleId.slice(6))) }),
|
||||
},
|
||||
};
|
||||
|
||||
test("starts real-wait transport files first without changing test membership", async () => {
|
||||
const sorted = await new RunnerSequencer(ctx).sort(files);
|
||||
assert.deepEqual(sorted, [files[1], files[3], files[4], files[0], files[2]]);
|
||||
assert.equal(new Set(sorted).size, files.length);
|
||||
assert.deepEqual(await new BaseSequencer(ctx).sort(files), files);
|
||||
});
|
||||
|
||||
test("selected subsets stay selected and retain default ordering", async () => {
|
||||
const selected = [files[2], files[0]];
|
||||
assert.deepEqual(await new RunnerSequencer(ctx).sort(selected), await new BaseSequencer(ctx).sort(selected));
|
||||
assert.deepEqual(await new RunnerSequencer(ctx).sort([files[4]]), [files[4]]);
|
||||
assert.deepEqual(await new RunnerSequencer(ctx).sort([]), []);
|
||||
});
|
||||
|
||||
test("keeps Vitest shard membership and schedules waits within each shard", async () => {
|
||||
const all = [];
|
||||
for (const index of [1, 2]) {
|
||||
const shardCtx = { ...ctx, config: { ...ctx.config, shard: { index, count: 2 } } };
|
||||
const sequencer = new RunnerSequencer(shardCtx);
|
||||
const shard = await sequencer.shard(files);
|
||||
assert.deepEqual(shard, await new BaseSequencer(shardCtx).shard(files));
|
||||
const sorted = await sequencer.sort(shard);
|
||||
assert.deepEqual(new Set(sorted), new Set(shard));
|
||||
all.push(...sorted);
|
||||
}
|
||||
assert.equal(all.length, files.length);
|
||||
assert.deepEqual(new Set(all), new Set(files));
|
||||
});
|
||||
|
||||
test("recognizes Windows paths", async () => {
|
||||
const deadline = { ...files[4], moduleId: "C:\\repo\\src\\live\\runnerd-codex-transport-deadlines.test.ts" };
|
||||
assert.deepEqual(await new RunnerSequencer(ctx).sort([files[0], deadline]), [deadline, files[0]]);
|
||||
});
|
||||
|
||||
test("starts measured slow provider files before short files without adding work", async () => {
|
||||
const streaming = { ...files[0], moduleId: "/repo/src/live/runnerd-opencode-streaming-tools.test.ts" };
|
||||
const launch = { ...files[0], moduleId: "/repo/src/control-plane/cursor-cloud-runner-launch.test.ts" };
|
||||
assert.deepEqual(await new RunnerSequencer(ctx).sort([files[0], streaming, files[4], launch]),
|
||||
[launch, files[4], streaming, files[0]]);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { HarnessDriver, HarnessSession, PersistedHarnessSession } from "../contracts/harness-driver.js";
|
||||
import { PROVIDER_STATE_INCOMPATIBLE, runnerRecoveryFailureClass } from "../contracts/harness-driver.js";
|
||||
import type { PrpEvent, PrpStructuredRunResult, PrpTerminalState } from "../protocol/replay-contract.js";
|
||||
import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js";
|
||||
import { HarnessDriverBackend } from "./harness-driver-backend.js";
|
||||
@@ -323,6 +324,36 @@ describe("HarnessDriverBackend", () => {
|
||||
expect(recoveredDispositionAllowance).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the runner's closed class of a failed recovery", async () => {
|
||||
const commandFailure = Object.defineProperty(new Error("PRP command run.attach failed"), "runnerCommandResult", {
|
||||
value: { status: "failed", commandType: "run.attach",
|
||||
result: { code: "command_execution_failed", message: "saved launch profile changed",
|
||||
classification: PROVIDER_STATE_INCOMPATIBLE } },
|
||||
enumerable: false,
|
||||
});
|
||||
expect(runnerRecoveryFailureClass(commandFailure)).toBe(PROVIDER_STATE_INCOMPATIBLE);
|
||||
expect(runnerRecoveryFailureClass(new Error("wrapped", { cause: commandFailure }))).toBe(PROVIDER_STATE_INCOMPATIBLE);
|
||||
expect(runnerRecoveryFailureClass(new Error(`text only: ${PROVIDER_STATE_INCOMPATIBLE}`))).toBeUndefined();
|
||||
const unclassified = Object.defineProperty(new Error("PRP command run.attach failed"), "runnerCommandResult", {
|
||||
value: { status: "failed", result: { code: "command_execution_failed", classification: "other" } },
|
||||
enumerable: false,
|
||||
});
|
||||
expect(runnerRecoveryFailureClass(unclassified)).toBeUndefined();
|
||||
|
||||
const backend = new HarnessDriverBackend({
|
||||
...driver,
|
||||
recoverSession: async () => ({ recovered: false, reason: String(commandFailure),
|
||||
failureClass: runnerRecoveryFailureClass(commandFailure) }),
|
||||
});
|
||||
await expect(backend.recoverSession({
|
||||
backendKind: "runner", driverKind: "fake", sessionId: "driver-1", providerSessionId: "provider-1",
|
||||
providerIdentity,
|
||||
identity: { runId: "run-2", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" },
|
||||
}, { signal: new AbortController().signal })).resolves.toEqual({
|
||||
recovered: false, reason: "Error: PRP command run.attach failed", failureClass: PROVIDER_STATE_INCOMPATIBLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a consumed disposition allowance in native snapshots", async () => {
|
||||
class ConsumedRecoverySession extends FakeHarnessSession {
|
||||
override async snapshot(): Promise<PersistedHarnessSession> {
|
||||
|
||||
@@ -162,7 +162,11 @@ export class HarnessDriverBackend implements NativeSessionBackend {
|
||||
recoveryOptions,
|
||||
);
|
||||
if (!recovered.recovered || recovered.session === undefined) {
|
||||
return { recovered: false, reason: recovered.reason };
|
||||
return {
|
||||
recovered: false,
|
||||
reason: recovered.reason,
|
||||
...(recovered.failureClass ? { failureClass: recovered.failureClass } : {}),
|
||||
};
|
||||
}
|
||||
try {
|
||||
assertProviderSessionIdentity(
|
||||
|
||||
@@ -496,10 +496,29 @@ export interface PersistedHarnessSession {
|
||||
| "allow_replacement_after_resume_failure";
|
||||
}
|
||||
|
||||
/** Runner proof that saved provider state cannot be restored by this runner
|
||||
* build. Restoring the same state again fails the same way. */
|
||||
export const PROVIDER_STATE_INCOMPATIBLE = "provider_state_incompatible";
|
||||
|
||||
export interface HarnessSessionRecoveryResult {
|
||||
recovered: boolean;
|
||||
session?: HarnessSession;
|
||||
reason?: string;
|
||||
/** Closed runner class of a deterministic recovery failure. */
|
||||
failureClass?: typeof PROVIDER_STATE_INCOMPATIBLE;
|
||||
}
|
||||
|
||||
/** Reads the closed class of an authenticated runner command failure. */
|
||||
export function runnerRecoveryFailureClass(
|
||||
error: unknown,
|
||||
depth = 0,
|
||||
): typeof PROVIDER_STATE_INCOMPATIBLE | undefined {
|
||||
if (!error || typeof error !== "object" || depth > 5) return undefined;
|
||||
const value = error as Record<string, unknown>;
|
||||
const result = value.runnerCommandResult as Record<string, unknown> | undefined;
|
||||
const detail = result?.result as Record<string, unknown> | undefined;
|
||||
if (detail?.classification === PROVIDER_STATE_INCOMPATIBLE) return PROVIDER_STATE_INCOMPATIBLE;
|
||||
return runnerRecoveryFailureClass(value.cause, depth + 1);
|
||||
}
|
||||
|
||||
export interface HarnessSession {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
HarnessRuntimeRequestHandoff,
|
||||
HarnessRuntimeRequestResolution,
|
||||
HarnessGoalOperation,
|
||||
HarnessSessionRecoveryResult,
|
||||
HarnessThreadGoal,
|
||||
HarnessThreadLineageEntry,
|
||||
NativeRuntimeContextCapabilities,
|
||||
@@ -82,6 +83,7 @@ export interface NativeSessionRecoveryResult {
|
||||
recovered: boolean;
|
||||
session?: NativeSession;
|
||||
reason?: string;
|
||||
failureClass?: HarnessSessionRecoveryResult["failureClass"];
|
||||
}
|
||||
|
||||
/** Runner-owned cause: the process was restored, but its active turn was lost. */
|
||||
@@ -278,6 +280,17 @@ export interface NativeSessionBackend {
|
||||
): Promise<NativeSessionRecoveryResult>;
|
||||
}
|
||||
|
||||
/** Same-session recovery is forbidden here, and the runner proved that the
|
||||
* saved provider session cannot be restored by its build. Retrying the same
|
||||
* run cannot succeed. */
|
||||
export class NativeProviderStateIncompatibleError extends Error {
|
||||
readonly code = "native_provider_state_incompatible";
|
||||
constructor(reason: string) {
|
||||
super(`native_session_recovery_failed: ${reason}`);
|
||||
this.name = "NativeProviderStateIncompatibleError";
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider failed terminal is not a missing completion proposal. */
|
||||
export class NativeProviderTerminalFailure extends Error {
|
||||
readonly code = "native_provider_terminal_failed";
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
PersistedHarnessTurnTerminal,
|
||||
} from "../../contracts/harness-driver.js";
|
||||
import { NativeSessionProtocolIntegrityError, nativeRestartInterruptedTurnId } from "../../contracts/native-session-backend.js";
|
||||
import { HarnessReconciliationError } from "../../contracts/harness-driver.js";
|
||||
import { HarnessReconciliationError, runnerRecoveryFailureClass } from "../../contracts/harness-driver.js";
|
||||
import {
|
||||
CODEX_CODEX_PROTOCOL_VERSION,
|
||||
CODEX_SKILLLESS_BASE_INSTRUCTIONS,
|
||||
@@ -677,7 +677,12 @@ export class CodexAppServerDriver implements HarnessDriver {
|
||||
await cancellation.close().catch(() => {});
|
||||
if (error instanceof NativeSessionProtocolIntegrityError) throw error;
|
||||
if (options.signal.aborted) options.signal.throwIfAborted();
|
||||
return { recovered: false, reason: redactCodexDiagnostic(String(error)) };
|
||||
const failureClass = runnerRecoveryFailureClass(error);
|
||||
return {
|
||||
recovered: false,
|
||||
reason: redactCodexDiagnostic(String(error)),
|
||||
...(failureClass ? { failureClass } : {}),
|
||||
};
|
||||
} finally {
|
||||
cancellation.detach();
|
||||
}
|
||||
|
||||
@@ -994,21 +994,32 @@ describe("Cursor Cloud native driver", () => {
|
||||
try {
|
||||
const turn = await f.session.startTurn({ message: { role: "user", text: rejection === "unsent_payload" ? "\u0001".repeat(400_000) : "Rejected task" } });
|
||||
await entered.promise;
|
||||
const observeHeldCheckpoint = async () => {
|
||||
const checkpointPath = join(f.root, (await readdir(f.root)).find(name => /^checkpoint-.*\.json$/.test(name))!);
|
||||
expect(JSON.parse(await readFile(checkpointPath, "utf8")).phase).not.toBe("idle");
|
||||
return checkpointPath;
|
||||
};
|
||||
let checkpointPath: string;
|
||||
if (overlap === "stop_write") {
|
||||
stop(); await stopHeld.promise;
|
||||
rejectCreate.resolve(); await new Promise<void>(accept => setImmediate(accept));
|
||||
// The Stop checkpoint gate still holds its cancellation clock here.
|
||||
checkpointPath = await observeHeldCheckpoint();
|
||||
releaseStop.resolve();
|
||||
await waitFor(() => pausedRejection || stopDone);
|
||||
await Promise.race([rejectionHeld.promise, stopped!]);
|
||||
expect(pausedRejection, "the definite rejection must reach durable settlement even while stopping").toBe(true);
|
||||
} else {
|
||||
rejectCreate.resolve(); await rejectionHeld.promise;
|
||||
// File observations must not consume Stop's 300ms settlement budget.
|
||||
checkpointPath = await observeHeldCheckpoint();
|
||||
stop(); await new Promise<void>(accept => setImmediate(accept));
|
||||
}
|
||||
expect(stopDone, "Stop must wait for the rejected-create checkpoint").toBe(false);
|
||||
expect(await f.session.read()).toMatchObject({ phase: "stopping" });
|
||||
await expect(f.session.startTurn({ message: { role: "user", text: "Too early" } })).rejects.toThrow("unresolved remote work");
|
||||
const checkpointPath = join(f.root, (await readdir(f.root)).find(name => /^checkpoint-.*\.json$/.test(name))!);
|
||||
expect(JSON.parse(await readFile(checkpointPath, "utf8")).phase).not.toBe("idle");
|
||||
expect(peer.getAgent).toHaveBeenCalledTimes(before.getAgent);
|
||||
expect(peer.getRun).toHaveBeenCalledTimes(before.getRun);
|
||||
expect(peer.cancelRun).toHaveBeenCalledTimes(before.cancel);
|
||||
releaseRejection.resolve();
|
||||
expect(await stopped).toEqual({ error: null });
|
||||
await waitFor(() => f.events.some(event => event.turnId === turn.turnId && event.eventType === "turn.failed" && event.payload.remoteWorkAccepted === false));
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
import {
|
||||
createRunnerProviderLaunch,
|
||||
runnerLaunchRequirementsDigest,
|
||||
type RunnerProviderLaunchRequirements,
|
||||
} from "./runner-provider-launch.js";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js";
|
||||
import { DurablePrpControlPlane } from "../control-plane/durable-prp-control-plane.js";
|
||||
import * as durableControlPlane from "../control-plane/durable-prp-control-plane.js";
|
||||
import { codexSemanticToolSpecs } from "../drivers/codex/codex-app-server-driver.js";
|
||||
import { createCapabilityRunnerdCodexTransport, readRunnerdArtifactBinding } from "./runnerd-codex-transport.js";
|
||||
import { defaultCapabilityRunnerdBinary, fakeCodex, fakeCodexArgs } from "../../test/support/runnerd-transport.js";
|
||||
|
||||
async function verifyLiveRunnerAdoption(
|
||||
mismatchedCheckpoint: boolean,
|
||||
mismatchedArtifact = false,
|
||||
goalMidTurn = false,
|
||||
detachBeforeCleanup = false,
|
||||
startWithoutCheckpoint = false,
|
||||
preparedTask = false,
|
||||
adoptionAuthorization: "allow" | "deny" | "legacy" | "legacy-missing" | "missing-binding" = "allow",
|
||||
toolHandlerOrder?: "before" | "after" | "completion-before" | "completion-after",
|
||||
uncertainAtClose = false,
|
||||
semanticRecoveryMode?: "denied-before-cancel" | "cleanup-only",
|
||||
stopExitProofFault?: "missing" | "false",
|
||||
) {
|
||||
const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-live-adopt-"));
|
||||
const pendingCompletion = toolHandlerOrder?.startsWith("completion-") === true;
|
||||
const completionPath = join(stateDirectory, "pending-completion.json");
|
||||
if (pendingCompletion) await writeFile(completionPath, JSON.stringify({
|
||||
schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Completed the synthetic work",
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] },
|
||||
evidence: [], verification: [], attentionRequests: [], artifacts: [],
|
||||
}));
|
||||
const server = createServer();
|
||||
let authority: DurablePrpControlPlane | null = null;
|
||||
const checkpoint = vi.fn(async () => undefined);
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!authority) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
authority.handleUpgrade(request, socket, "/runner", head);
|
||||
});
|
||||
await new Promise<void>((resolveListen) =>
|
||||
server.listen(0, "127.0.0.1", resolveListen),
|
||||
);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string")
|
||||
throw new Error("Expected adoption test listener");
|
||||
const registration = vi.fn(async (next: DurablePrpControlPlane) => {
|
||||
authority = next;
|
||||
return {
|
||||
connectUrl: `ws://127.0.0.1:${address.port}/runner`,
|
||||
...(mismatchedArtifact || uncertainAtClose || semanticRecoveryMode === "cleanup-only" ? { checkpoint } : {}),
|
||||
release: async () => {
|
||||
if (authority === next) authority = null;
|
||||
},
|
||||
};
|
||||
});
|
||||
const identity = {
|
||||
runnerInstanceId: "runner-live-adopt",
|
||||
environmentLeaseId: "lease-live-adopt",
|
||||
runId: "run-live-adopt",
|
||||
normalizedSessionId: "session-live-adopt",
|
||||
turnId: "turn-live-adopt",
|
||||
itemId: "item-live-adopt",
|
||||
};
|
||||
const sharedOptions = {
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: fakeCodexArgs(stateDirectory, ...(pendingCompletion ? ["--terminal-result-file", completionPath] : toolHandlerOrder ? ["--emit-tool-call"] : []), ...(goalMidTurn ? ["--goal-autostart", "--goal-item-trigger", join(stateDirectory, "emit-goal-item")] : [])),
|
||||
stateDirectory,
|
||||
prpIdentity: identity,
|
||||
lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 },
|
||||
controlPlaneRegistration: registration,
|
||||
};
|
||||
const prepareLaunch = vi.fn(async (requirements: RunnerProviderLaunchRequirements) => ({
|
||||
configurationId: "adopted-preparation", requirementsDigest: runnerLaunchRequirementsDigest(requirements),
|
||||
provider: createRunnerProviderLaunch(requirements, { options: { codexArgs: sharedOptions.codexArgs },
|
||||
root: stateDirectory, cwd: tmpdir(), codexHome: join(stateDirectory, "codex-home"),
|
||||
codexCommand: fakeCodex, providerNodeCommand: process.execPath, opencodeProxyPath: "", acpxSidecarPath: "" }),
|
||||
}));
|
||||
const options = { ...sharedOptions, ...(preparedTask ? {
|
||||
prepareLaunch, runnerArtifact: readRunnerdArtifactBinding(defaultCapabilityRunnerdBinary()),
|
||||
runnerProcessLauncher: (spec: durableControlPlane.RunnerProcessLaunchSpec) => {
|
||||
const child = spawn(spec.command, [...spec.args], { cwd: spec.cwd, env: { ...process.env, ...spec.environment },
|
||||
detached: process.platform !== "win32", stdio: "ignore" });
|
||||
return { child, processGroupId: child.pid ?? null, completion: new Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }>((resolveExit, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => resolveExit({ code, signal, stdout: "", stderr: "" }));
|
||||
}) };
|
||||
},
|
||||
} : {}) };
|
||||
const first = createCapabilityRunnerdCodexTransport(options);
|
||||
const pendingTool = vi.fn(() => new Promise<Record<string, unknown>>(() => undefined));
|
||||
if (toolHandlerOrder) first.transport.setServerRequestHandler(pendingTool);
|
||||
let releaseRecovery!: () => void;
|
||||
const recoveryGate = new Promise<void>(resolve => { releaseRecovery = resolve; });
|
||||
const recoveryDenied = new Error("execution authority revoked after Stop");
|
||||
const recoveredTool = vi.fn(async () => {
|
||||
if (semanticRecoveryMode) {
|
||||
// Model server authority denying replay after Stop cleared the task lock.
|
||||
// The gate holds that denial until reattachment has returned its session,
|
||||
// before the caller invokes the eventual onSession cancellation callback.
|
||||
await recoveryGate;
|
||||
throw recoveryDenied;
|
||||
}
|
||||
return { result: { receipt: "known-outcome" }, isError: false };
|
||||
});
|
||||
const redispatchedTool = vi.fn(async () => ({ success: true, contentItems: [] }));
|
||||
let runnerPid: number | null = null;
|
||||
let runnerStartedAt: string | null = null;
|
||||
let adopted: ReturnType<typeof createCapabilityRunnerdCodexTransport> | null =
|
||||
null;
|
||||
let restoreIntervalSpies: () => void = () => undefined;
|
||||
try {
|
||||
let opened: Record<string, unknown>;
|
||||
try {
|
||||
opened = await first.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: toolHandlerOrder && !pendingCompletion ? [{ name: "get_task_context", description: "Read current task", inputSchema: { type: "object", properties: {}, additionalProperties: false } }] : codexSemanticToolSpecs(),
|
||||
});
|
||||
} catch (error) {
|
||||
const stderr = await readFile(
|
||||
join(stateDirectory, "diagnostics", "runnerd.stderr.log"),
|
||||
"utf8",
|
||||
).catch(() => "");
|
||||
throw new Error(
|
||||
`${String(error)}\n${JSON.stringify(first.evidence())}${stderr ? `\n${stderr}` : ""}`,
|
||||
);
|
||||
}
|
||||
runnerPid = first.evidence().runnerPid;
|
||||
runnerStartedAt = first.transport.processInfo().startedAt;
|
||||
expect(runnerPid).toEqual(expect.any(Number));
|
||||
|
||||
if (goalMidTurn) {
|
||||
await first.transport.request("thread/goal/set", { objective: "Recover a live goal", status: "active" });
|
||||
for await (const event of first.transport.notifications()) {
|
||||
if (event.method === "turn/started") break;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolHandlerOrder) {
|
||||
await first.transport.request("turn/start", { input: [{ type: "text", text: "Read current task" }] });
|
||||
await vi.waitFor(() => expect(pendingTool).toHaveBeenCalledOnce(), { timeout: 5_000 });
|
||||
}
|
||||
await first.detachControllerForRestart();
|
||||
expect(() => process.kill(runnerPid!, 0)).not.toThrow();
|
||||
if (goalMidTurn) {
|
||||
await writeFile(join(stateDirectory, "emit-goal-item"), "emit");
|
||||
// runnerd need not poll the provider into its PRP outbox while disconnected.
|
||||
// Wait for flushed provider output, not a platform-dependent final poll
|
||||
// racing the disconnect. Adoption must still bind that buffered item.
|
||||
await vi.waitFor(async () => {
|
||||
expect(await readFile(join(stateDirectory, "emit-goal-item.sent"), "utf8")).toBe("sent");
|
||||
}, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
const controlPlaneStatePath = join(
|
||||
stateDirectory,
|
||||
"control-plane",
|
||||
"control-plane-state.json",
|
||||
);
|
||||
const compactProviderIdentityEvents = async () => {
|
||||
const controlPlaneState = JSON.parse(
|
||||
await readFile(controlPlaneStatePath, "utf8"),
|
||||
) as { committedEvents: Array<{ eventType: string }> };
|
||||
controlPlaneState.committedEvents =
|
||||
controlPlaneState.committedEvents.filter(
|
||||
(event) =>
|
||||
event.eventType !== "harness.ready" &&
|
||||
event.eventType !== "session.started" &&
|
||||
event.eventType !== "session.resumed",
|
||||
);
|
||||
await writeFile(
|
||||
controlPlaneStatePath,
|
||||
`${JSON.stringify(controlPlaneState, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
};
|
||||
if (!startWithoutCheckpoint) await compactProviderIdentityEvents();
|
||||
|
||||
if (preparedTask) {
|
||||
if (adoptionAuthorization !== "legacy") await rm(join(stateDirectory, "launch-preparation.json"));
|
||||
prepareLaunch.mockClear();
|
||||
registration.mockClear();
|
||||
if (adoptionAuthorization === "missing-binding") {
|
||||
const retained = JSON.parse(await readFile(controlPlaneStatePath, "utf8"));
|
||||
delete retained.preparedLaunchConfigurations;
|
||||
await writeFile(controlPlaneStatePath, JSON.stringify(retained), { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
const authorizeRunnerAdoption = vi.fn(async () => {
|
||||
expect(registration).not.toHaveBeenCalled();
|
||||
if (adoptionAuthorization === "deny") throw new Error("project grant revoked");
|
||||
});
|
||||
const duplicateLauncher = vi.fn(() => {
|
||||
throw new Error("duplicate runner spawn attempted");
|
||||
});
|
||||
const openedThread = opened.thread as Record<string, unknown>;
|
||||
const signal = vi.fn(() => true);
|
||||
const probeError = new Error("fixture remote process identity is unavailable");
|
||||
let rejectLiveness = false;
|
||||
const intervals = uncertainAtClose ? vi.spyOn(globalThis, "setInterval") : null;
|
||||
const clearedIntervals = uncertainAtClose ? vi.spyOn(globalThis, "clearInterval") : null;
|
||||
restoreIntervalSpies = () => { intervals?.mockRestore(); clearedIntervals?.mockRestore(); };
|
||||
adopted = createCapabilityRunnerdCodexTransport({
|
||||
...options,
|
||||
...(preparedTask && !adoptionAuthorization.startsWith("legacy") ? { authorizeRunnerAdoption } : {}),
|
||||
...(uncertainAtClose ? { closeGraceMs: 2_000 } : {}),
|
||||
...(semanticRecoveryMode === "cleanup-only" ? { recoveryPurpose: "stop_cleanup" as const } : {}),
|
||||
// Hash different stable bytes without replacing the real runner artifact
|
||||
// used by concurrent tests. Adoption must never execute this path.
|
||||
...(mismatchedArtifact
|
||||
? {
|
||||
runnerBinary: resolve(import.meta.dirname, "../../package.json"),
|
||||
runnerReconnectGraceMs: 150,
|
||||
}
|
||||
: {}),
|
||||
...(toolHandlerOrder && !pendingCompletion ? { recoverSemanticToolInput: recoveredTool } : {}),
|
||||
resumeDynamicTools: pendingCompletion ? codexSemanticToolSpecs() : toolHandlerOrder ? [{ name: "get_task_context", description: "Read current task", inputSchema: { type: "object", properties: {}, additionalProperties: false } }] : [],
|
||||
resumeProviderSession: startWithoutCheckpoint ? undefined : {
|
||||
driverSessionId: String(openedThread.id),
|
||||
providerSessionId: mismatchedCheckpoint
|
||||
? "wrong-provider-session"
|
||||
: String(openedThread.sessionId),
|
||||
},
|
||||
runnerProcessLauncher: duplicateLauncher,
|
||||
adoptExistingRunner: {
|
||||
pid: runnerPid!,
|
||||
processGroupId: runnerPid,
|
||||
startedAt: runnerStartedAt,
|
||||
signal,
|
||||
isAlive: () => {
|
||||
if (rejectLiveness) return Promise.reject(probeError);
|
||||
try {
|
||||
process.kill(runnerPid!, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
if (preparedTask && ["deny", "legacy-missing", "missing-binding"].includes(adoptionAuthorization)) {
|
||||
await expect(adopted.transport.request("thread/read", {})).rejects.toThrow(
|
||||
adoptionAuthorization === "deny" ? "project grant revoked" : adoptionAuthorization === "legacy-missing"
|
||||
? "runner_launch_recovery_requirements_unavailable" : "runner_launch_recovery_configuration_unavailable");
|
||||
expect(registration).not.toHaveBeenCalled();
|
||||
expect(prepareLaunch).not.toHaveBeenCalled();
|
||||
expect(duplicateLauncher).not.toHaveBeenCalled();
|
||||
expect(signal).not.toHaveBeenCalled();
|
||||
expect(() => process.kill(runnerPid!, 0)).not.toThrow();
|
||||
expect(authorizeRunnerAdoption).toHaveBeenCalledTimes(adoptionAuthorization === "deny" ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
if (mismatchedArtifact) {
|
||||
await expect(
|
||||
adopted.transport.request("thread/read", {}),
|
||||
).rejects.toThrow("native_adopted_runner_authentication_timeout");
|
||||
expect(authority?.activeRunnerConnectionCount()).toBe(0);
|
||||
await expect(
|
||||
adopted.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "must not be dispatched" }],
|
||||
}),
|
||||
).rejects.toThrow("native_adopted_runner_authentication_timeout");
|
||||
await adopted.transport.close();
|
||||
expect(signal).not.toHaveBeenCalled();
|
||||
expect(checkpoint).not.toHaveBeenCalled();
|
||||
expect(duplicateLauncher).not.toHaveBeenCalled();
|
||||
expect(() => process.kill(runnerPid!, 0)).not.toThrow();
|
||||
const retained = JSON.parse(
|
||||
await readFile(controlPlaneStatePath, "utf8"),
|
||||
) as {
|
||||
identity: unknown;
|
||||
commands: Array<{ type: string; status: string }>;
|
||||
};
|
||||
expect(retained.identity).toEqual(identity);
|
||||
expect(
|
||||
retained.commands.some(
|
||||
(command) =>
|
||||
command.type === "runner.drain" && command.status === "pending",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(retained.commands.map((command) => command.type)).not.toEqual(
|
||||
expect.arrayContaining(["runner.suspend"]),
|
||||
);
|
||||
expect(retained.commands.map((command) => command.type)).not.toEqual(
|
||||
expect.arrayContaining(["turn.stop"]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mismatchedCheckpoint) {
|
||||
await expect(
|
||||
adopted.transport.request("thread/read", {}),
|
||||
).rejects.toThrow("native_adopted_provider_identity_mismatch");
|
||||
expect(duplicateLauncher).not.toHaveBeenCalled();
|
||||
expect(() => process.kill(runnerPid!, 0)).not.toThrow();
|
||||
return;
|
||||
}
|
||||
if (toolHandlerOrder?.endsWith("before")) adopted.transport.setServerRequestHandler(redispatchedTool);
|
||||
await expect(adopted.transport.request(startWithoutCheckpoint ? "thread/start" : "thread/read", {})).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
thread: expect.objectContaining({ id: "codex-thread-1" }),
|
||||
}),
|
||||
);
|
||||
if (toolHandlerOrder?.endsWith("after")) adopted.transport.setServerRequestHandler(redispatchedTool);
|
||||
if (semanticRecoveryMode) {
|
||||
const retained = authority as DurablePrpControlPlane | null;
|
||||
expect(retained).not.toBeNull();
|
||||
expect(retained!.store.state.committedEvents.some(event => event.eventType === "semantic_tool.input")).toBe(true);
|
||||
expect(retained!.store.state.commands.filter(command => command.type === "turn.start")).toHaveLength(1);
|
||||
expect(retained!.store.state.commands.some(command => command.type === "turn.interrupt")).toBe(false);
|
||||
if (semanticRecoveryMode === "denied-before-cancel") {
|
||||
await vi.waitFor(() => expect(recoveredTool).toHaveBeenCalledOnce());
|
||||
// This is a real Core replay callback, not an artificial transport error.
|
||||
// Deliver its denial before giving the caller its cancellation turn.
|
||||
releaseRecovery();
|
||||
await vi.waitFor(async () => {
|
||||
await expect(adopted!.transport.request("thread/read", {})).rejects.toBe(recoveryDenied);
|
||||
});
|
||||
await expect(adopted.transport.request("turn/interrupt", {})).rejects.toBe(recoveryDenied);
|
||||
expect(retained!.store.state.commands.some(command => command.type === "turn.interrupt")).toBe(false);
|
||||
} else {
|
||||
// Let all startup/handler microtasks and a real reconciliation read run.
|
||||
// Cleanup must not enter replay even while its authority would deny it.
|
||||
await adopted.transport.request("thread/read", {});
|
||||
expect(recoveredTool).not.toHaveBeenCalled();
|
||||
expect(redispatchedTool).not.toHaveBeenCalled();
|
||||
releaseRecovery();
|
||||
await expect(adopted.transport.request("turn/interrupt", {})).resolves.toEqual({});
|
||||
expect(retained!.store.state.commands.filter(command => command.type === "turn.interrupt")).toMatchObject([{ status: "completed" }]);
|
||||
}
|
||||
expect(redispatchedTool).not.toHaveBeenCalled();
|
||||
expect(retained!.store.state.commands.some(command => command.type === "semantic_tool.result")).toBe(false);
|
||||
expect(retained!.store.state.commands.filter(command => command.type === "turn.start")).toHaveLength(1);
|
||||
expect(duplicateLauncher).not.toHaveBeenCalled();
|
||||
if (semanticRecoveryMode === "cleanup-only") {
|
||||
const faultedStopProof = vi.fn();
|
||||
if (stopExitProofFault) {
|
||||
const queue = retained!.queueCommand.bind(retained!);
|
||||
vi.spyOn(retained!, "queueCommand").mockImplementation((type, ...args) => {
|
||||
const command = queue(type, ...args);
|
||||
if (type === "turn.stop") {
|
||||
let result = command.result;
|
||||
Object.defineProperty(command, "result", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => result,
|
||||
set: value => {
|
||||
const receipt = value as { result?: Record<string, unknown> } | null;
|
||||
if (receipt?.result && typeof receipt.result === "object") {
|
||||
faultedStopProof(receipt.result.providerExitConfirmed);
|
||||
const stopped = { ...receipt.result };
|
||||
if (stopExitProofFault === "missing") delete stopped.providerExitConfirmed;
|
||||
else stopped.providerExitConfirmed = false;
|
||||
result = { ...receipt, result: stopped };
|
||||
} else result = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
return command;
|
||||
});
|
||||
await expect(adopted.transport.close()).rejects.toBeInstanceOf(NativeSessionCloseUnrecoverableError);
|
||||
expect(faultedStopProof).toHaveBeenCalledWith(true);
|
||||
expect(checkpoint).toHaveBeenCalledExactlyOnceWith("unsettled");
|
||||
} else {
|
||||
await expect(adopted.transport.close()).resolves.toBeUndefined();
|
||||
expect(checkpoint).toHaveBeenCalledExactlyOnceWith("settled");
|
||||
expect(retained!.store.state.commands.filter(command => command.type === "turn.stop")).toMatchObject([{
|
||||
status: "completed", result: { result: { providerExitConfirmed: true } },
|
||||
}]);
|
||||
}
|
||||
expect(retained!.semanticToolResultsSettled()).toBe(false);
|
||||
expect(retained!.store.state.commands.some(command => command.type === "semantic_tool.result")).toBe(false);
|
||||
expect(retained!.store.state.committedEvents.some(event => event.eventType === "semantic_tool.input")).toBe(true);
|
||||
if (!stopExitProofFault) {
|
||||
expect(retained!.store.state.commands.filter(command => command.type === "runner.suspend")).toMatchObject([{ status: "completed" }]);
|
||||
const runnerState = JSON.parse(await readFile(join(stateDirectory, "runner", "runner-state.json"), "utf8"));
|
||||
expect(runnerState).toMatchObject({ ...identity, lifecycle: "suspended" });
|
||||
}
|
||||
const providerState = JSON.parse(await readFile(join(stateDirectory, "runner", "codex-provider-state.json"), "utf8"));
|
||||
expect(providerState).toMatchObject({ activeProviderTurnId: null, pendingEvents: [], queuedEvents: [] });
|
||||
expect(recoveredTool).not.toHaveBeenCalled();
|
||||
expect(redispatchedTool).not.toHaveBeenCalled();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (toolHandlerOrder) {
|
||||
await vi.waitFor(() => {
|
||||
expect(pendingCompletion ? redispatchedTool : recoveredTool).toHaveBeenCalledOnce();
|
||||
const result = authority!.store.state.commands.find(command => command.type === "semantic_tool.result");
|
||||
expect(result?.status).toBe("completed");
|
||||
}, { timeout: 5_000 });
|
||||
if (pendingCompletion) {
|
||||
expect(recoveredTool).not.toHaveBeenCalled();
|
||||
expect(redispatchedTool).toHaveBeenCalledWith(expect.objectContaining({ params: expect.objectContaining({ tool: "paperclip_finish" }) }));
|
||||
} else expect(redispatchedTool).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(adopted.evidence().runnerPid).toBe(runnerPid);
|
||||
expect(adopted.transport.processInfo().startedAt).toBe(runnerStartedAt);
|
||||
if (startWithoutCheckpoint) {
|
||||
const retained = JSON.parse(await readFile(controlPlaneStatePath, "utf8"));
|
||||
for (const type of ["run.prepare", "session.open"]) {
|
||||
expect(retained.commands.filter((command: { type: string }) => command.type === type)).toHaveLength(1);
|
||||
}
|
||||
}
|
||||
if (goalMidTurn) {
|
||||
const observed = await Promise.race([
|
||||
(async () => {
|
||||
for await (const notification of adopted!.transport.notifications()) {
|
||||
if (notification.method === "item/started") return notification;
|
||||
}
|
||||
throw new Error("recovered goal item was lost");
|
||||
})(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("recovered item timed out")), 5_000)),
|
||||
]);
|
||||
expect(observed.params).toMatchObject({ threadId: "codex-thread-1", turnId: "provider-goal-turn-1" });
|
||||
}
|
||||
expect(duplicateLauncher).not.toHaveBeenCalled();
|
||||
if (preparedTask) {
|
||||
if (adoptionAuthorization === "legacy") {
|
||||
expect(prepareLaunch).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ identity }), expect.objectContaining({ configurationId: "adopted-preparation" }));
|
||||
expect(authorizeRunnerAdoption).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(prepareLaunch).not.toHaveBeenCalled();
|
||||
expect(authorizeRunnerAdoption).toHaveBeenCalledExactlyOnceWith("adopted-preparation", identity);
|
||||
}
|
||||
expect((await adopted.transport.request("thread/read", {})).thread).toMatchObject({ cwd: tmpdir() });
|
||||
}
|
||||
if (uncertainAtClose) {
|
||||
const before = JSON.parse(await readFile(controlPlaneStatePath, "utf8"));
|
||||
const retainedAuthority = authority as DurablePrpControlPlane | null;
|
||||
rejectLiveness = true;
|
||||
await expect(adopted.transport.close()).rejects.toBe(probeError);
|
||||
expect(retainedAuthority?.activeRunnerConnectionCount()).toBe(0);
|
||||
expect(intervals!.mock.results.length).toBeGreaterThan(0);
|
||||
for (const interval of intervals!.mock.results) if (interval.type === "return") expect(clearedIntervals).toHaveBeenCalledWith(interval.value);
|
||||
const notificationsClosed = (async () => { for await (const _event of adopted!.transport.notifications()) {} return true; })();
|
||||
let queueDeadline: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
expect(await Promise.race([notificationsClosed, new Promise<boolean>(resolve => { queueDeadline = setTimeout(() => resolve(false), 250); })])).toBe(true);
|
||||
} finally { clearTimeout(queueDeadline); }
|
||||
await expect(adopted.transport.close()).rejects.toBe(probeError);
|
||||
expect(adopted.transport.processInfo()).toMatchObject({ exited: false });
|
||||
expect(() => process.kill(runnerPid!, 0)).not.toThrow();
|
||||
expect(signal).not.toHaveBeenCalled();
|
||||
expect(checkpoint).not.toHaveBeenCalled();
|
||||
expect(duplicateLauncher).not.toHaveBeenCalled();
|
||||
const retained = JSON.parse(await readFile(controlPlaneStatePath, "utf8"));
|
||||
expect(retained.identity).toEqual(before.identity);
|
||||
expect(retained.commands).toEqual(before.commands);
|
||||
return;
|
||||
}
|
||||
expect(adopted.evidence().diagnostics).toContain(
|
||||
`adopted runner ${runnerPid} authenticated to its durable PRP authority`,
|
||||
);
|
||||
if (!startWithoutCheckpoint) {
|
||||
expect(adopted.evidence().diagnostics).toContain(
|
||||
"restored adopted provider identity from the exact durable checkpoint after PRP event compaction; awaiting live confirmation",
|
||||
);
|
||||
expect(adopted.evidence().diagnostics).toContain(
|
||||
"confirmed adopted provider identity against authenticated recovery session.snapshot",
|
||||
);
|
||||
}
|
||||
if (detachBeforeCleanup) {
|
||||
await adopted.detachControllerForRestart();
|
||||
await adopted.transport.close("old controller finalizer");
|
||||
expect(signal).not.toHaveBeenCalled();
|
||||
expect(() => process.kill(runnerPid!, 0)).not.toThrow();
|
||||
const retained = JSON.parse(await readFile(controlPlaneStatePath, "utf8"));
|
||||
const commandTypes = retained.commands.map((command: { type: string }) => command.type);
|
||||
expect(commandTypes).not.toContain("turn.stop");
|
||||
expect(commandTypes).not.toContain("runner.suspend");
|
||||
}
|
||||
} finally {
|
||||
releaseRecovery();
|
||||
await adopted?.transport.close().catch(() => undefined);
|
||||
if (uncertainAtClose) {
|
||||
const providerPid = adopted?.evidence().codexPid;
|
||||
if (providerPid) {
|
||||
try { process.kill(providerPid, "SIGKILL"); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
||||
}
|
||||
await (authority as DurablePrpControlPlane | null)?.stop();
|
||||
}
|
||||
restoreIntervalSpies();
|
||||
if (runnerPid) {
|
||||
try {
|
||||
process.kill(-runnerPid, "SIGKILL");
|
||||
} catch {
|
||||
// The adopted runner normally exits after its durable suspend command.
|
||||
}
|
||||
}
|
||||
server.closeAllConnections();
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolveClose) =>
|
||||
server.close(() => resolveClose()),
|
||||
);
|
||||
}
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it(
|
||||
"adopts a live runner on the same durable authority without spawning a duplicate",
|
||||
() => verifyLiveRunnerAdoption(false),
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("adopts a prepared runner after losing its controller launch cache without preparing resources again",
|
||||
() => verifyLiveRunnerAdoption(false, false, false, false, false, true), 30_000);
|
||||
|
||||
it.each(["deny", "legacy", "legacy-missing", "missing-binding"] as const)("preserves adoption authorization with %s",
|
||||
mode => verifyLiveRunnerAdoption(false, false, false, false, false, true, mode), 30_000);
|
||||
|
||||
it("retains authenticated adopted-runner ownership when a close probe rejects", () => verifyLiveRunnerAdoption(false, false, false, false, false, false, "allow", undefined, true), 30_000);
|
||||
|
||||
it(
|
||||
"blocks adopted runner artifact drift without duplicate launch, checkpoint replacement, or process signals",
|
||||
() => verifyLiveRunnerAdoption(false, true),
|
||||
15_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"rejects a live runner whose provider identity mismatches the compacted checkpoint",
|
||||
() => verifyLiveRunnerAdoption(true),
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("binds buffered mid-goal items only after the authenticated recovery snapshot", () => verifyLiveRunnerAdoption(false, false, true), 30_000);
|
||||
|
||||
it("keeps an adopted runner alive when the detached controller finalizer closes", () => verifyLiveRunnerAdoption(false, false, true, true), 30_000);
|
||||
|
||||
it("adopts an opening session without a checkpoint instead of bootstrapping a duplicate provider", () => verifyLiveRunnerAdoption(false, false, false, false, true), 30_000);
|
||||
|
||||
it.each(["before", "after"] as const)("settles a saved tool outcome with the handler installed %s recovery startup", order =>
|
||||
verifyLiveRunnerAdoption(false, false, false, false, false, false, "allow", order), 30_000);
|
||||
|
||||
it.each(["completion-before", "completion-after"] as const)("recovers a pending finish without an ordinary-tool lookup (%s)", order =>
|
||||
verifyLiveRunnerAdoption(false, false, false, false, false, false, "allow", order), 30_000);
|
||||
|
||||
it("exposes semantic recovery before cancellation when the authority has been revoked", () =>
|
||||
verifyLiveRunnerAdoption(false, false, false, false, false, false, "allow", "before", false, "denied-before-cancel"), 30_000);
|
||||
|
||||
it.each(["before", "after", "completion-before", "completion-after"] as const)("allows cleanup-only adoption to cancel without replaying a pending semantic input (%s)", order =>
|
||||
verifyLiveRunnerAdoption(false, false, false, false, false, false, "allow", order, false, "cleanup-only"), 30_000);
|
||||
|
||||
it.each(["missing", "false"] as const)("refuses cleanup settlement when the exact stop receipt has %s provider exit proof", fault =>
|
||||
verifyLiveRunnerAdoption(false, false, false, false, false, false, "allow", "before", false, "cleanup-only", fault), 30_000);
|
||||
+1077
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,769 @@
|
||||
import { mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { ControlPlanePort } from "../contracts/control-plane-port.js";
|
||||
import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
|
||||
import type { NativeSession, NativeSessionBackend } from "../contracts/native-session-backend.js";
|
||||
import type { PrpEvent, PrpStructuredRunResult, PrpTerminalState } from "../protocol/replay-contract.js";
|
||||
import { executeNativeSession } from "../native-session-runtime.js";
|
||||
import { createCodexTaskEnvelope } from "../contracts/codex.js";
|
||||
import { CodexAppServerDriver } from "../drivers/codex/codex-app-server-driver.js";
|
||||
import { createCapabilityRunnerdCodexTransport } from "./runnerd-codex-transport.js";
|
||||
import { defaultCapabilityRunnerdBinary, expectTurnStarted, fakeCodex, fakeCodexArgs } from "../../test/support/runnerd-transport.js";
|
||||
|
||||
it("rejects active work and buffered tools from a resumed stopped checkpoint", async () => {
|
||||
const stateDirectory = await mkdtemp(
|
||||
join(tmpdir(), "runnerd-stopped-resume-active-"),
|
||||
);
|
||||
const identity = {
|
||||
runnerInstanceId: "runner-stopped-active",
|
||||
environmentLeaseId: "lease-stopped-active",
|
||||
runId: "run-stopped-first",
|
||||
normalizedSessionId: "session-stopped-active",
|
||||
turnId: "turn-stopped-first",
|
||||
itemId: "item-stopped-first",
|
||||
};
|
||||
const options = {
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: fakeCodexArgs(
|
||||
stateDirectory,
|
||||
"--linger-after-turn-start",
|
||||
"--resume-unowned-turn-when-marked",
|
||||
"--emit-tool-call-on-resume",
|
||||
),
|
||||
stateDirectory,
|
||||
lifecyclePolicy: { mode: "per_turn" as const, idleTimeoutMs: null },
|
||||
};
|
||||
const first = createCapabilityRunnerdCodexTransport({
|
||||
...options,
|
||||
prpIdentity: identity,
|
||||
});
|
||||
let second:
|
||||
ReturnType<typeof createCapabilityRunnerdCodexTransport> | undefined;
|
||||
const semanticHandler = vi.fn(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
first.transport.setServerRequestHandler(semanticHandler);
|
||||
try {
|
||||
const opened = await first.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [
|
||||
{
|
||||
name: "get_task_context",
|
||||
description: "Read the current task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await first.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "Wait for another instruction." }],
|
||||
});
|
||||
await expectTurnStarted(
|
||||
first.transport.notifications()[Symbol.asyncIterator](),
|
||||
);
|
||||
await first.transport.close();
|
||||
const providerPath = join(
|
||||
stateDirectory,
|
||||
"runner",
|
||||
"codex-provider-state.json",
|
||||
);
|
||||
expect(JSON.parse(await readFile(providerPath, "utf8"))).toMatchObject({
|
||||
lifecycle: "prepared",
|
||||
activeProviderTurnId: null,
|
||||
});
|
||||
await writeFile(join(stateDirectory, "resume-unowned-turn"), "armed");
|
||||
const secondIdentity = {
|
||||
...identity,
|
||||
runId: "run-stopped-second",
|
||||
turnId: "turn-stopped-second",
|
||||
itemId: "item-stopped-second",
|
||||
};
|
||||
second = createCapabilityRunnerdCodexTransport({
|
||||
...options,
|
||||
prpIdentity: secondIdentity,
|
||||
});
|
||||
second.transport.setServerRequestHandler(semanticHandler);
|
||||
await expect(second.transport.request("thread/read", {})).rejects.toThrow(
|
||||
"prepared provider checkpoint resumed unexpected active work",
|
||||
);
|
||||
// Exercise the normal durable transfer before inspecting the rejection:
|
||||
// provider pendingEvents is an acknowledged queue, not an event journal.
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const provider = JSON.parse(await readFile(providerPath, "utf8"));
|
||||
expect(provider.pendingEvents).toEqual([]);
|
||||
const control = JSON.parse(
|
||||
await readFile(
|
||||
join(stateDirectory, "control-plane", "control-plane-state.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as {
|
||||
committedEvents: Array<{
|
||||
eventType: string;
|
||||
envelope: { payload: { payload: { code?: string } } };
|
||||
}>;
|
||||
};
|
||||
const rejections = control.committedEvents.filter(
|
||||
(event) =>
|
||||
event.eventType === "harness.diagnostic" &&
|
||||
event.envelope.payload.payload.code ===
|
||||
"prepared_provider_checkpoint_has_active_work",
|
||||
);
|
||||
expect(rejections).toEqual([
|
||||
expect.objectContaining({
|
||||
logicalEffectCount: 1,
|
||||
envelope: expect.objectContaining({
|
||||
...secondIdentity,
|
||||
payload: expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
code: "prepared_provider_checkpoint_has_active_work",
|
||||
paperclipAccepted: false,
|
||||
providerReportedActive: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
const closed = JSON.parse(await readFile(providerPath, "utf8"));
|
||||
expect(closed).toMatchObject({
|
||||
lifecycle: "closed",
|
||||
threadId: (opened.thread as Record<string, unknown>).id,
|
||||
activeProviderTurnId: null,
|
||||
});
|
||||
expect(semanticHandler).not.toHaveBeenCalled();
|
||||
expect(closed.pendingEvents).toEqual([]);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
first.transport.close(),
|
||||
second?.transport.close(),
|
||||
]);
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("does not retry a real memoized transport close whose suspension proof is unavailable", async () => {
|
||||
const identity = {
|
||||
runId: "run-recovery",
|
||||
sessionId: "session-recovery",
|
||||
companyId: "company-recovery",
|
||||
issueId: "issue-recovery",
|
||||
agentId: "agent-recovery",
|
||||
};
|
||||
|
||||
const result: PrpStructuredRunResult = {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: "done",
|
||||
summary: "Recovered native work completed.",
|
||||
completionClaim: {
|
||||
contractRevision: "1",
|
||||
objectiveSatisfied: true,
|
||||
criteria: [
|
||||
{ criterionId: "objective", status: "satisfied", evidenceRefs: [] },
|
||||
],
|
||||
remainingWork: [],
|
||||
},
|
||||
evidence: [],
|
||||
verification: [{ commandOrCheck: "recovery", status: "passed" }],
|
||||
attentionRequests: [],
|
||||
artifacts: [],
|
||||
};
|
||||
|
||||
const terminal: PrpTerminalState = {
|
||||
schema: "paperclip.prp.terminal.v1",
|
||||
turnTerminalState: "completed",
|
||||
runTerminalState: "succeeded",
|
||||
reportedWorkDisposition: "done",
|
||||
};
|
||||
|
||||
const input: NativeExecutionInputV1 = {
|
||||
schema: "paperclip.native-execution-input.v1",
|
||||
binding: {
|
||||
companyId: identity.companyId,
|
||||
runId: identity.runId,
|
||||
issueId: identity.issueId,
|
||||
agentId: identity.agentId,
|
||||
executionWorkspaceId: "workspace-recovery",
|
||||
},
|
||||
task: {
|
||||
identifier: "PAP-RECOVERY",
|
||||
title: "Recover native work",
|
||||
description: null,
|
||||
prompt: "# PAP-RECOVERY: Recover native work",
|
||||
workMode: "standard",
|
||||
},
|
||||
workspace: {
|
||||
cwd: "/workspace",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
branchName: null,
|
||||
},
|
||||
session: {
|
||||
normalizedSessionId: identity.sessionId,
|
||||
driverKind: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
provider: { kind: "codex", model: null },
|
||||
completionContract: {
|
||||
id: "contract-recovery",
|
||||
sha256: "contract-recovery-sha",
|
||||
schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: {
|
||||
revision: "1",
|
||||
objective: "Recover native work",
|
||||
criteria: [{ id: "objective", requirement: "Complete after recovery" }],
|
||||
},
|
||||
},
|
||||
interactionResponses: [],
|
||||
credentialBindings: [],
|
||||
};
|
||||
|
||||
function runnerEvent(
|
||||
sourceSeq: number,
|
||||
eventType: PrpEvent["eventType"],
|
||||
payload: Record<string, unknown> = {},
|
||||
): PrpEvent {
|
||||
return {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `runner-recovery:${identity.runId}:${sourceSeq}`,
|
||||
sourceSeq,
|
||||
sourceInstanceId: "runner-recovery",
|
||||
sourceKind: "runner",
|
||||
runId: identity.runId,
|
||||
normalizedSessionId: identity.sessionId,
|
||||
turnId: "turn-recovery",
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: 0,
|
||||
emittedAt: "2026-08-09T00:00:00.000Z",
|
||||
payload,
|
||||
};
|
||||
}
|
||||
const stateDirectory = await mkdtemp(
|
||||
join(tmpdir(), "native-close-quarantine-"),
|
||||
);
|
||||
const readRunnerState = vi.fn(async () => ({
|
||||
schema: "paperclip.runner.durable.state.v1",
|
||||
runnerInstanceId: "runner-close-quarantine",
|
||||
environmentLeaseId: "lease-close-quarantine",
|
||||
runId: identity.runId,
|
||||
normalizedSessionId: identity.sessionId,
|
||||
turnId: "turn-close-quarantine",
|
||||
itemId: "item-close-quarantine",
|
||||
lifecycle: "ready",
|
||||
}));
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: resolve(
|
||||
import.meta.dirname,
|
||||
"../../runner/target/debug/fake-codex-app-server",
|
||||
),
|
||||
codexArgs: ["--state-file", join(stateDirectory, "fake-codex-state.json")],
|
||||
stateDirectory,
|
||||
closeGraceMs: 400,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
prpIdentity: await readRunnerState(),
|
||||
readRunnerState,
|
||||
// A checkpoint owner requires durable suspension proof. A local transport
|
||||
// without a checkpoint can simply terminate its process on close.
|
||||
controlPlaneRegistration: async (authority) => {
|
||||
await authority.start();
|
||||
return {
|
||||
connectUrl: authority.connectUrl,
|
||||
checkpoint: async () => {},
|
||||
release: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
try {
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: stateDirectory,
|
||||
dynamicTools: [],
|
||||
});
|
||||
const failedClose = bundle.transport.close();
|
||||
const failure = await failedClose.catch((error: unknown) => error);
|
||||
expect(failure).toMatchObject({
|
||||
code: "native_session_close_unrecoverable",
|
||||
});
|
||||
expect(bundle.transport.close()).toBe(failedClose);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const close = vi.fn(({ reason }: { reason: string }) =>
|
||||
bundle.transport.close(reason),
|
||||
);
|
||||
const capabilities = {
|
||||
resume: true,
|
||||
typedEvents: true,
|
||||
steering: false,
|
||||
interruption: true,
|
||||
structuredResult: true,
|
||||
};
|
||||
const session: NativeSession = {
|
||||
identity: () => identity,
|
||||
async capabilities() {
|
||||
return capabilities;
|
||||
},
|
||||
async *events() {
|
||||
yield runnerEvent(1, "turn.completed");
|
||||
},
|
||||
async startTurn() {
|
||||
return { turnId: "turn-recovery" };
|
||||
},
|
||||
async result() {
|
||||
return { result, terminal, turnId: "turn-recovery" };
|
||||
},
|
||||
async snapshot() {
|
||||
return {
|
||||
backendKind: "mock",
|
||||
sessionId: identity.sessionId,
|
||||
identity,
|
||||
providerSessionId: "provider-recovery",
|
||||
cursor: null,
|
||||
activeTurnId: null,
|
||||
pendingRuntimeRequests: [],
|
||||
lineage: [],
|
||||
};
|
||||
},
|
||||
close,
|
||||
};
|
||||
const openSession = vi.fn(async () => session);
|
||||
const backend: NativeSessionBackend = {
|
||||
async descriptor() {
|
||||
return {
|
||||
kind: "mock",
|
||||
name: "real-memoized-close-quarantine",
|
||||
version: "1",
|
||||
capabilities,
|
||||
};
|
||||
},
|
||||
openSession,
|
||||
};
|
||||
const port: ControlPlanePort = {
|
||||
async openRun() {},
|
||||
async checkpointSession() {},
|
||||
async appendEvent() {
|
||||
return {
|
||||
cursor: 1,
|
||||
highestContiguousSourceSeq: 1,
|
||||
disposition: "committed",
|
||||
};
|
||||
},
|
||||
async replayEvents() {
|
||||
return { events: [], highestContiguousSourceSeq: 0 };
|
||||
},
|
||||
async completeRun() {},
|
||||
};
|
||||
const execute = () =>
|
||||
executeNativeSession({
|
||||
input,
|
||||
backend,
|
||||
controlPlane: port,
|
||||
runnerInstanceId: "runner-recovery",
|
||||
controlPlaneInstanceId: "control-recovery",
|
||||
requireSessionCloseBeforeReturn: true,
|
||||
});
|
||||
await expect(execute()).rejects.toBe(failure);
|
||||
const readsAfterClose = readRunnerState.mock.calls.length;
|
||||
await expect(execute()).rejects.toMatchObject({
|
||||
code: "native_session_cleanup_quarantined",
|
||||
recovery: "operator_required",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(600_000);
|
||||
await expect(execute()).rejects.toMatchObject({
|
||||
code: "native_session_cleanup_quarantined",
|
||||
});
|
||||
expect(openSession).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(readRunnerState).toHaveBeenCalledTimes(readsAfterClose);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await bundle.transport.close().catch(() => undefined);
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it.each(["not_suspended", "wrong_identity"] as const)(
|
||||
"does not report local runner close healthy with %s durable evidence",
|
||||
async (mode) => {
|
||||
const stateDirectory = await mkdtemp(
|
||||
join(tmpdir(), "runnerd-local-close-unproven-"),
|
||||
);
|
||||
const identity = {
|
||||
runnerInstanceId: "runner-close-unproven",
|
||||
environmentLeaseId: "lease-close-unproven",
|
||||
runId: "run-close-unproven",
|
||||
normalizedSessionId: "session-close-unproven",
|
||||
turnId: "turn-close-unproven",
|
||||
itemId: "item-close-unproven",
|
||||
};
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: fakeCodexArgs(stateDirectory),
|
||||
stateDirectory,
|
||||
closeGraceMs: 400,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
prpIdentity: identity,
|
||||
readRunnerState: async () => ({
|
||||
schema: "paperclip.runner.durable.state.v1",
|
||||
...identity,
|
||||
...(mode === "wrong_identity" ? { runId: "some-other-run" } : {}),
|
||||
lifecycle: mode === "not_suspended" ? "ready" : "suspended",
|
||||
}),
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
try {
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [],
|
||||
});
|
||||
await expect(bundle.transport.close()).rejects.toThrow(
|
||||
"runner did not durably suspend before checkpoint",
|
||||
);
|
||||
const control = JSON.parse(
|
||||
await readFile(
|
||||
join(stateDirectory, "control-plane", "control-plane-state.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
expect(control.identity).toEqual(identity);
|
||||
expect(await readdir(stateDirectory)).toContain("runner");
|
||||
} finally {
|
||||
await bundle.transport.close().catch(() => undefined);
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "interrupted",
|
||||
args: ["--hold-turn"],
|
||||
terminal: "turn.interrupted",
|
||||
disposition: "needs_review",
|
||||
},
|
||||
{
|
||||
label: "failed",
|
||||
args: ["--fail-turn-immediately"],
|
||||
terminal: "turn.failed",
|
||||
disposition: "needs_review",
|
||||
},
|
||||
{
|
||||
label: "completed",
|
||||
args: [],
|
||||
terminal: "turn.completed",
|
||||
disposition: "done",
|
||||
},
|
||||
])("retains a $label runner's result before closing its provider turn", async ({
|
||||
label, args, terminal, disposition,
|
||||
}) => {
|
||||
const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-stop-result-"));
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: fakeCodexArgs(stateDirectory, ...args),
|
||||
stateDirectory,
|
||||
});
|
||||
const driver = new CodexAppServerDriver({
|
||||
taskEnvelope: createCodexTaskEnvelope({
|
||||
objective: "Stop and retain unfinished work.",
|
||||
}),
|
||||
environment: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: join(tmpdir(), "runnerd-stop-host-home"),
|
||||
PAPERCLIP_WORKSPACE_CWD: stateDirectory,
|
||||
},
|
||||
approvalPolicy: "never",
|
||||
transportFactory: () => bundle.transport,
|
||||
});
|
||||
const session = await driver.openSession({
|
||||
runId: "run-stop-result",
|
||||
normalizedSessionId: "session-stop-result",
|
||||
workingDirectory: stateDirectory,
|
||||
});
|
||||
try {
|
||||
const turn = await session.startTurn({
|
||||
message: { role: "user", text: "Keep working until interrupted." },
|
||||
});
|
||||
if (label === "interrupted") {
|
||||
await session.interrupt({
|
||||
turnId: turn.turnId,
|
||||
reason: "Stopped by the user",
|
||||
});
|
||||
}
|
||||
const events: PrpEvent[] = [];
|
||||
for await (const event of session.events()) {
|
||||
events.push(event);
|
||||
if (event.eventType === "session.failed" || event.eventType === terminal) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Let already committed durable suffix events reach the facade as well.
|
||||
await bundle.transport.request("thread/read", {});
|
||||
const snapshot = await session.snapshot();
|
||||
expect(events.some((event) => event.eventType === "session.failed")).toBe(false);
|
||||
expect(events.map((event) => event.eventType)).toContain("run.result.proposed");
|
||||
expect(events.at(-1)?.eventType).toBe(terminal);
|
||||
expect(snapshot).toMatchObject({
|
||||
activeTurnId: null,
|
||||
semanticResult: {
|
||||
turnId: turn.turnId,
|
||||
result: { reportedWorkDisposition: disposition },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await session.close().catch(() => undefined);
|
||||
await rm(stateDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 25,
|
||||
});
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.each([0, 193 * 1024 * 1024])(
|
||||
"probes an exact-authority resume with %i extra journal bytes and confirms its live provider identity",
|
||||
async (extraJournalBytes) => {
|
||||
const stateDirectory = await mkdtemp(
|
||||
join(tmpdir(), "runnerd-exact-authority-resume-"),
|
||||
);
|
||||
const identity = {
|
||||
runnerInstanceId: "runner-exact-resume",
|
||||
environmentLeaseId: "lease-exact-resume",
|
||||
runId: "run-exact-resume",
|
||||
normalizedSessionId: "session-exact-resume",
|
||||
turnId: "turn-exact-resume",
|
||||
itemId: "item-exact-resume",
|
||||
};
|
||||
const options = {
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: fakeCodexArgs(stateDirectory, "--durable-turn-ids"),
|
||||
stateDirectory,
|
||||
lifecyclePolicy: { mode: "per_turn" as const, idleTimeoutMs: null },
|
||||
prpIdentity: identity,
|
||||
};
|
||||
const first = createCapabilityRunnerdCodexTransport(options);
|
||||
first.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
let providerThread: { id: string; sessionId: string } | null = null;
|
||||
try {
|
||||
const opened = await first.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [],
|
||||
});
|
||||
const thread = opened.thread as Record<string, unknown>;
|
||||
providerThread = {
|
||||
id: String(thread.id),
|
||||
sessionId: String(thread.sessionId),
|
||||
};
|
||||
} finally {
|
||||
await first.transport.close();
|
||||
}
|
||||
if (providerThread === null) {
|
||||
throw new Error(
|
||||
"exact-authority fixture did not return a provider thread",
|
||||
);
|
||||
}
|
||||
|
||||
const statePath = join(
|
||||
stateDirectory,
|
||||
"control-plane",
|
||||
"control-plane-state.json",
|
||||
);
|
||||
if (extraJournalBytes > 0) {
|
||||
const padding = Buffer.alloc(1024 * 1024, 0x20);
|
||||
const stateHandle = await open(statePath, "a");
|
||||
try {
|
||||
for (let remaining = extraJournalBytes; remaining > 0;) {
|
||||
const bytesToWrite = Math.min(remaining, padding.length);
|
||||
await stateHandle.write(padding, 0, bytesToWrite);
|
||||
remaining -= bytesToWrite;
|
||||
}
|
||||
} finally {
|
||||
await stateHandle.close();
|
||||
}
|
||||
expect((await stat(statePath)).size).toBeGreaterThan(
|
||||
64 * 1024 * 1024,
|
||||
);
|
||||
}
|
||||
const beforeResume = JSON.parse(await readFile(statePath, "utf8")) as {
|
||||
commands: Array<{ type: string }>;
|
||||
committedEvents: Array<{ eventType: string }>;
|
||||
};
|
||||
expect(
|
||||
beforeResume.commands.some((command) => command.type === "run.attach"),
|
||||
).toBe(false);
|
||||
const priorResumeEvents = beforeResume.committedEvents.filter(
|
||||
(event) => event.eventType === "session.resumed",
|
||||
).length;
|
||||
const priorSnapshots = beforeResume.commands.filter(
|
||||
(command) => command.type === "session.snapshot",
|
||||
).length;
|
||||
|
||||
const resumed = createCapabilityRunnerdCodexTransport({
|
||||
...options,
|
||||
resumeProviderSession: {
|
||||
driverSessionId: providerThread.id,
|
||||
providerSessionId: providerThread.sessionId,
|
||||
},
|
||||
});
|
||||
resumed.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
try {
|
||||
const read = await resumed.transport.request("thread/read", {});
|
||||
expect(read.thread).toMatchObject(providerThread);
|
||||
const afterResume = JSON.parse(await readFile(statePath, "utf8")) as {
|
||||
commands: Array<{ commandId: string; type: string; status: string }>;
|
||||
committedEvents: Array<{ eventType: string }>;
|
||||
};
|
||||
expect(afterResume.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
commandId: expect.stringMatching(/^command_resume_probe_/),
|
||||
type: "runner.drain",
|
||||
status: "completed",
|
||||
}),
|
||||
);
|
||||
expect(afterResume.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.snapshot",
|
||||
status: "completed",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
afterResume.commands.filter(
|
||||
(command) => command.type === "session.snapshot",
|
||||
),
|
||||
).toHaveLength(priorSnapshots + 2);
|
||||
// The authenticated snapshot above proves the live provider identity.
|
||||
// Control-first dispatch may deliver that command before the independent
|
||||
// session event is ingested. Still require exactly one durable event;
|
||||
// don't mistake an immediate file read for an event-delivery barrier.
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const delivered = JSON.parse(await readFile(statePath, "utf8")) as {
|
||||
committedEvents: Array<{ eventType: string }>;
|
||||
};
|
||||
expect(
|
||||
delivered.committedEvents.filter(
|
||||
(event) => event.eventType === "session.resumed",
|
||||
),
|
||||
).toHaveLength(priorResumeEvents + 1);
|
||||
},
|
||||
{ timeout: 3_000, interval: 25 },
|
||||
);
|
||||
} finally {
|
||||
await resumed.transport.close();
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("still fails closed when a real close grace period cannot fit a durable suspension round trip", async () => {
|
||||
const stateDirectory = await mkdtemp(
|
||||
join(tmpdir(), "runnerd-close-grace-too-small-"),
|
||||
);
|
||||
const identity = {
|
||||
runnerInstanceId: "runner-close-grace-too-small",
|
||||
environmentLeaseId: "lease-close-grace-too-small",
|
||||
runId: "run-close-grace-too-small",
|
||||
normalizedSessionId: "session-close-grace-too-small",
|
||||
turnId: "turn-close-grace-too-small",
|
||||
itemId: "item-close-grace-too-small",
|
||||
};
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: fakeCodexArgs(stateDirectory),
|
||||
stateDirectory,
|
||||
// No real durable command round trip can complete this fast. A wider
|
||||
// budget for the provider-drain proof must not turn this barrier into
|
||||
// one that always passes; it still needs the actual proof to arrive.
|
||||
closeGraceMs: 1,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
prpIdentity: identity,
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
try {
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [],
|
||||
});
|
||||
const closed = bundle.transport.close();
|
||||
await expect(closed).rejects.toMatchObject({
|
||||
code: "native_session_close_unrecoverable",
|
||||
semanticToolsSettled: true,
|
||||
});
|
||||
// Retirement preserves the original rejected promise and its proof.
|
||||
expect(bundle.transport.close()).toBe(closed);
|
||||
} finally {
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("persists an active provider as settled before bounded suspension", async () => {
|
||||
const stateDirectory = await mkdtemp(
|
||||
join(tmpdir(), "runnerd-active-suspension-"),
|
||||
);
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: fakeCodexArgs(stateDirectory, "--linger-after-turn-start"),
|
||||
stateDirectory,
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
try {
|
||||
await bundle.transport.request("initialize", {});
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [],
|
||||
});
|
||||
await bundle.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "Wait for another instruction." }],
|
||||
});
|
||||
const notifications = bundle.transport
|
||||
.notifications()
|
||||
[Symbol.asyncIterator]();
|
||||
await expectTurnStarted(notifications);
|
||||
await bundle.transport.close();
|
||||
|
||||
const providerState = JSON.parse(
|
||||
await readFile(
|
||||
join(stateDirectory, "runner", "codex-provider-state.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
expect(providerState).toMatchObject({
|
||||
lifecycle: "prepared",
|
||||
activeProviderTurnId: null,
|
||||
});
|
||||
} finally {
|
||||
await bundle.transport.close();
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -15,10 +15,12 @@ import type {
|
||||
PersistedNativeSession,
|
||||
} from "./contracts/native-session-backend.js";
|
||||
import {
|
||||
NativeProviderStateIncompatibleError,
|
||||
NativeSessionCloseUnrecoverableError,
|
||||
NativeSessionCleanupQuarantinedError,
|
||||
NativeSessionProtocolIntegrityError,
|
||||
} from "./contracts/native-session-backend.js";
|
||||
import { PROVIDER_STATE_INCOMPATIBLE } from "./contracts/harness-driver.js";
|
||||
import type {
|
||||
PrpEvent,
|
||||
PrpStructuredRunResult,
|
||||
@@ -5641,6 +5643,40 @@ describe("executeNativeSession recovery", () => {
|
||||
expect(openSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("types a runner-proven incompatible session when same-session recovery is required", async () => {
|
||||
for (const providerRecoveryPolicy of ["same_session_only", "allow_replacement_after_governed_wait"] as const) {
|
||||
const checkpoint: PersistedNativeSession = {
|
||||
backendKind: "mock", sessionId: "driver-incompatible", identity,
|
||||
providerSessionId: "provider-incompatible", providerRecoveryPolicy,
|
||||
cursor: "0", activeTurnId: null, pendingRuntimeRequests: [], lineage: [],
|
||||
};
|
||||
const openRun = vi.fn(async () => undefined);
|
||||
const openSession = vi.fn();
|
||||
const openReplacementSession = vi.fn();
|
||||
const backend: NativeSessionBackend = {
|
||||
descriptor: async () => ({ kind: "mock", name: "incompatible", version: "1",
|
||||
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true } }),
|
||||
openSession, openReplacementSession,
|
||||
recoverSession: async () => ({ recovered: false, reason: "saved launch profile changed",
|
||||
failureClass: PROVIDER_STATE_INCOMPATIBLE }),
|
||||
};
|
||||
const port: ControlPlanePort = {
|
||||
openRun, appendEvent: vi.fn(), completeRun: vi.fn(),
|
||||
loadSessionCheckpoint: async () => structuredClone(checkpoint),
|
||||
replayEvents: async () => ({ events: [], highestContiguousSourceSeq: 0 }),
|
||||
};
|
||||
const failure = await executeNativeSession({
|
||||
input, backend, controlPlane: port,
|
||||
runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery",
|
||||
}).catch((error: unknown) => error);
|
||||
expect(failure, providerRecoveryPolicy).toBeInstanceOf(NativeProviderStateIncompatibleError);
|
||||
expect((failure as Error).message).toBe("native_session_recovery_failed: saved launch profile changed");
|
||||
expect(openRun).not.toHaveBeenCalled();
|
||||
expect(openSession).not.toHaveBeenCalled();
|
||||
expect(openReplacementSession).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds paginated recovery replay with one signal and observes a late rejection", async () => {
|
||||
vi.useFakeTimers();
|
||||
let rejectStalledReplay = (_error: Error) => {};
|
||||
|
||||
@@ -19,8 +19,9 @@ import type {
|
||||
NativeSessionBackend,
|
||||
} from "./contracts/native-session-backend.js";
|
||||
import type { PersistedNativeSession } from "./contracts/native-session-backend.js";
|
||||
import type { HarnessThreadGoal } from "./contracts/harness-driver.js";
|
||||
import { PROVIDER_STATE_INCOMPATIBLE, type HarnessThreadGoal } from "./contracts/harness-driver.js";
|
||||
import {
|
||||
NativeProviderStateIncompatibleError,
|
||||
NativeProviderTerminalFailure,
|
||||
NativeSessionCloseUnrecoverableError,
|
||||
NativeSessionCleanupQuarantinedError,
|
||||
@@ -2164,6 +2165,9 @@ export async function executeNativeSession(
|
||||
};
|
||||
if (!recovery.recovered || !recovery.session) {
|
||||
if (!replacementAllowed) {
|
||||
if (recovery.failureClass === PROVIDER_STATE_INCOMPATIBLE) {
|
||||
throw new NativeProviderStateIncompatibleError(recovery.reason ?? "unknown");
|
||||
}
|
||||
throw new Error(
|
||||
`native_session_recovery_failed: ${recovery.reason ?? "unknown"}`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import {
|
||||
NATIVE_RUNTIME_ASSET_SCHEMA,
|
||||
PAPERCLIP_EXECUTION_PROMPT,
|
||||
PAPERCLIP_EXECUTION_PROMPT_REVISION,
|
||||
canonicalNativeRuntimeContextDigest,
|
||||
nativeRuntimePromptDigest,
|
||||
type NativeRuntimeContextSnapshot,
|
||||
} from "../../src/contracts/runtime-context.js";
|
||||
import { defaultCapabilityRunnerdBinary as qualifiedCapabilityRunnerdBinary } from "../../src/live/runnerd-codex-transport.js";
|
||||
|
||||
export const defaultCapabilityRunnerdBinary = () =>
|
||||
process.env.PAPERCLIP_ATTACH_TRANSITION_RUNNER ??
|
||||
qualifiedCapabilityRunnerdBinary();
|
||||
|
||||
export async function expectTurnStarted(
|
||||
notifications: AsyncIterator<{ method: string }>,
|
||||
) {
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
const next = await notifications.next();
|
||||
expect(next.done).not.toBe(true);
|
||||
if (next.value.method === "turn/started") return;
|
||||
// Startup ownership and capability facts can precede the active turn.
|
||||
expect(next.value.method).toBe("paperclip/canonicalProviderEvent");
|
||||
}
|
||||
throw new Error(
|
||||
"provider turn did not start within the bounded notification prefix",
|
||||
);
|
||||
}
|
||||
|
||||
export const fakeCodex = resolve(
|
||||
import.meta.dirname,
|
||||
"../../runner/target/debug/fake-codex-app-server",
|
||||
);
|
||||
|
||||
export function fakeCodexArgs(stateDirectory: string, ...args: string[]): string[] {
|
||||
return [
|
||||
"--state-file",
|
||||
join(stateDirectory, "fake-codex-state.json"),
|
||||
...args,
|
||||
];
|
||||
}
|
||||
|
||||
export function assignedRuntimeContext(
|
||||
skillRoot: string,
|
||||
instructionRoot: string,
|
||||
): NativeRuntimeContextSnapshot {
|
||||
const digest = "0".repeat(64);
|
||||
const value = {
|
||||
prompt: {
|
||||
revision: PAPERCLIP_EXECUTION_PROMPT_REVISION,
|
||||
text: PAPERCLIP_EXECUTION_PROMPT,
|
||||
digest: nativeRuntimePromptDigest(),
|
||||
},
|
||||
instructions: {
|
||||
entryPath: "AGENTS.md",
|
||||
bundle: {
|
||||
schema: NATIVE_RUNTIME_ASSET_SCHEMA,
|
||||
digest,
|
||||
manifestDigest: digest,
|
||||
rootPath: instructionRoot,
|
||||
fileCount: 1,
|
||||
totalBytes: 1,
|
||||
},
|
||||
},
|
||||
skills: [
|
||||
{
|
||||
key: "company/assigned",
|
||||
runtimeName: "assigned",
|
||||
versionId: "version-1",
|
||||
bundle: {
|
||||
schema: NATIVE_RUNTIME_ASSET_SCHEMA,
|
||||
digest,
|
||||
manifestDigest: digest,
|
||||
rootPath: skillRoot,
|
||||
fileCount: 1,
|
||||
totalBytes: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
mcp: { assignmentSetId: "assigned", digest, bindingId: "binding" },
|
||||
} satisfies Omit<NativeRuntimeContextSnapshot, "aggregateDigest">;
|
||||
return {
|
||||
...value,
|
||||
aggregateDigest: canonicalNativeRuntimeContextDigest(value),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { availableParallelism } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
import { RunnerSequencer } from "./scripts/vitest-sequencer.mjs";
|
||||
|
||||
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
@@ -17,6 +19,8 @@ export default defineConfig({
|
||||
? { reporters: [...configDefaults.reporters, resolve(packageRoot, "../../scripts/ci-profile/vitest-reporter.mjs")] }
|
||||
: {}),
|
||||
environment: "node",
|
||||
...(process.env.GITHUB_ACTIONS === "true" ? { maxWorkers: Math.min(4, availableParallelism()) } : {}),
|
||||
sequence: { sequencer: RunnerSequencer },
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
@@ -27,6 +31,7 @@ export default defineConfig({
|
||||
"examples/scenario-explorer/src/**/*.test.tsx",
|
||||
"scripts/local-runner-browser-server.test.mjs",
|
||||
"scripts/live-console-browser-server.test.mjs",
|
||||
"scripts/vitest-sequencer.test.mjs",
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -65,6 +65,24 @@ provider-supplied error name that may contain a credential. This is a privacy
|
||||
boundary, not enum canonicalization. Test the value reaching the telemetry
|
||||
helper as well as the separate crash-reporting and local logging sinks.
|
||||
|
||||
## Server Startup Privacy
|
||||
|
||||
The startup helper emits the raw Node.js operating system family once when an
|
||||
enabled server telemetry client initializes. It omits OS versions, CPU
|
||||
architecture, hostnames, and other machine details. The usual telemetry opt-out
|
||||
and CI suppression apply before client initialization. If state storage fails
|
||||
at startup, the server disables telemetry for that process and continues to run.
|
||||
The warning contains no storage path or underlying error. Container and WSL servers
|
||||
report their runtime platform; this does not identify the underlying physical
|
||||
host's OS. Count distinct installation identifiers by platform to compare
|
||||
installations, since restart event counts also reflect restart frequency.
|
||||
|
||||
Contract syncs may also register existing proposals. Remove each adopted
|
||||
proposal marker and update its helper and transport tests in the same change.
|
||||
The connector helpers preserve raw enum values and send only catalog connector
|
||||
keys and the approved lifecycle and invocation dimensions. Connection names,
|
||||
credentials, tool arguments, and tool results stay out of those events.
|
||||
|
||||
## Interaction Resolver Events
|
||||
|
||||
`interaction.created` records the interaction kind and whether the create
|
||||
|
||||
+21
-12
@@ -28,7 +28,7 @@ function makeClient(config?: { enabled?: boolean }) {
|
||||
};
|
||||
}
|
||||
|
||||
function trackAllProposedConnectorEvents(client: TelemetryClient) {
|
||||
function trackAllConnectorEvents(client: TelemetryClient) {
|
||||
trackConnectionCreated(client, {
|
||||
connector_key: "github",
|
||||
transport: "mcp_remote",
|
||||
@@ -56,28 +56,33 @@ function trackAllProposedConnectorEvents(client: TelemetryClient) {
|
||||
});
|
||||
}
|
||||
|
||||
describe("proposed connector events against the real TelemetryClient", () => {
|
||||
describe("registered connector events against the real TelemetryClient", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("cannot queue or send: state stays untouched and nothing hits the wire", async () => {
|
||||
it("queues and sends the three registered connector events", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
||||
const { client, stateFactory } = makeClient();
|
||||
|
||||
trackAllProposedConnectorEvents(client);
|
||||
trackAllConnectorEvents(client);
|
||||
await client.flush();
|
||||
|
||||
expect(stateFactory).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(stateFactory).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0]?.[1]?.body));
|
||||
expect(body.events.map((event: { name: string }) => event.name)).toEqual([
|
||||
"connection.created", "connection.updated", "connection.invoked",
|
||||
]);
|
||||
});
|
||||
|
||||
it("differential control: a registered event through the same client does send", async () => {
|
||||
it("batches connector events with other registered events", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
||||
const { client } = makeClient();
|
||||
|
||||
trackAllProposedConnectorEvents(client);
|
||||
trackAllConnectorEvents(client);
|
||||
client.track("project.created", {});
|
||||
await client.flush();
|
||||
|
||||
@@ -86,15 +91,18 @@ describe("proposed connector events against the real TelemetryClient", () => {
|
||||
String((vi.mocked(fetch).mock.calls[0]?.[1] as RequestInit).body),
|
||||
);
|
||||
expect(body.events).toEqual([
|
||||
expect.objectContaining({ name: "connection.created" }),
|
||||
expect.objectContaining({ name: "connection.updated" }),
|
||||
expect.objectContaining({ name: "connection.invoked" }),
|
||||
expect.objectContaining({ name: "project.created" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports the three connector event names as unregistered", () => {
|
||||
it("reports the three connector event names as registered", () => {
|
||||
const { client } = makeClient();
|
||||
expect(client.isRegisteredEventName("connection.created")).toBe(false);
|
||||
expect(client.isRegisteredEventName("connection.updated")).toBe(false);
|
||||
expect(client.isRegisteredEventName("connection.invoked")).toBe(false);
|
||||
expect(client.isRegisteredEventName("connection.created")).toBe(true);
|
||||
expect(client.isRegisteredEventName("connection.updated")).toBe(true);
|
||||
expect(client.isRegisteredEventName("connection.invoked")).toBe(true);
|
||||
expect(client.isRegisteredEventName("project.created")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -102,6 +110,7 @@ describe("proposed connector events against the real TelemetryClient", () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
||||
const { client, stateFactory } = makeClient({ enabled: false });
|
||||
|
||||
trackAllConnectorEvents(client);
|
||||
client.track("project.created", {});
|
||||
await client.flush();
|
||||
|
||||
@@ -156,70 +156,69 @@ export function trackAgentTaskRun(
|
||||
export function trackConnectionCreated(
|
||||
client: TelemetryClient,
|
||||
dims: {
|
||||
connector_key: RawDimension<"custom">;
|
||||
transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">;
|
||||
auth_kind: RawDimension<"oauth" | "api_key" | "none">;
|
||||
setup_flow: RawDimension<"gallery" | "api" | "example">;
|
||||
status: RawDimension<"draft" | "active" | "disabled" | "archived">;
|
||||
connector_key: string;
|
||||
transport: RawDimension<EventDimensionsMap["connection.created"]["transport"]>;
|
||||
auth_kind: RawDimension<EventDimensionsMap["connection.created"]["auth_kind"]>;
|
||||
setup_flow: RawDimension<EventDimensionsMap["connection.created"]["setup_flow"]>;
|
||||
status: RawDimension<EventDimensionsMap["connection.created"]["status"]>;
|
||||
enabled: boolean;
|
||||
},
|
||||
): void {
|
||||
client.track(
|
||||
// @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/13578): measure which catalog connectors installations create connections for
|
||||
"connection.created",
|
||||
dims,
|
||||
);
|
||||
client.track("connection.created", {
|
||||
...dims,
|
||||
transport: asEventDimension(dims.transport),
|
||||
auth_kind: asEventDimension(dims.auth_kind),
|
||||
setup_flow: asEventDimension(dims.setup_flow),
|
||||
status: asEventDimension(dims.status),
|
||||
});
|
||||
}
|
||||
|
||||
export function trackConnectionUpdated(
|
||||
client: TelemetryClient,
|
||||
dims: {
|
||||
connector_key: RawDimension<"custom">;
|
||||
transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">;
|
||||
auth_kind: RawDimension<"oauth" | "api_key" | "none">;
|
||||
change_source: RawDimension<
|
||||
| "api"
|
||||
| "gallery"
|
||||
| "oauth_callback"
|
||||
| "credential_refresh"
|
||||
| "archive"
|
||||
| "example"
|
||||
>;
|
||||
previous_status: RawDimension<"draft" | "active" | "disabled" | "archived">;
|
||||
status: RawDimension<"draft" | "active" | "disabled" | "archived">;
|
||||
connector_key: string;
|
||||
transport: RawDimension<EventDimensionsMap["connection.updated"]["transport"]>;
|
||||
auth_kind: RawDimension<EventDimensionsMap["connection.updated"]["auth_kind"]>;
|
||||
change_source: RawDimension<EventDimensionsMap["connection.updated"]["change_source"]>;
|
||||
previous_status: RawDimension<EventDimensionsMap["connection.updated"]["previous_status"]>;
|
||||
status: RawDimension<EventDimensionsMap["connection.updated"]["status"]>;
|
||||
previous_enabled: boolean;
|
||||
enabled: boolean;
|
||||
},
|
||||
): void {
|
||||
client.track(
|
||||
// @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/13578): measure connector lifecycle transitions (configured, paused, archived) after creation
|
||||
"connection.updated",
|
||||
dims,
|
||||
);
|
||||
client.track("connection.updated", {
|
||||
...dims,
|
||||
transport: asEventDimension(dims.transport),
|
||||
auth_kind: asEventDimension(dims.auth_kind),
|
||||
change_source: asEventDimension(dims.change_source),
|
||||
previous_status: asEventDimension(dims.previous_status),
|
||||
status: asEventDimension(dims.status),
|
||||
});
|
||||
}
|
||||
|
||||
export function trackConnectionInvoked(
|
||||
client: TelemetryClient,
|
||||
dims: {
|
||||
connector_key: RawDimension<"custom">;
|
||||
transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">;
|
||||
status: RawDimension<
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "denied"
|
||||
| "cancelled"
|
||||
| "timed_out"
|
||||
| "rate_limited"
|
||||
>;
|
||||
origin: RawDimension<"setup_test" | "agent" | "user" | "system" | "plugin">;
|
||||
connector_key: string;
|
||||
transport: RawDimension<EventDimensionsMap["connection.invoked"]["transport"]>;
|
||||
status: RawDimension<EventDimensionsMap["connection.invoked"]["status"]>;
|
||||
origin: RawDimension<EventDimensionsMap["connection.invoked"]["origin"]>;
|
||||
duration_seconds?: number;
|
||||
},
|
||||
): void {
|
||||
client.track(
|
||||
// @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/13578): measure whether connected connectors are successfully used and where invocations fail; records completed invocation attempts and their terminal status, never invocation starts
|
||||
"connection.invoked",
|
||||
dims,
|
||||
);
|
||||
client.track("connection.invoked", {
|
||||
...dims,
|
||||
transport: asEventDimension(dims.transport),
|
||||
status: asEventDimension(dims.status),
|
||||
origin: asEventDimension(dims.origin),
|
||||
});
|
||||
}
|
||||
|
||||
export function trackServerStarted(
|
||||
client: TelemetryClient,
|
||||
dims: { os_platform: RawDimension<EventDimensionsMap["server.started"]["os_platform"]> },
|
||||
): void {
|
||||
client.track("server.started", { os_platform: asEventDimension(dims.os_platform) });
|
||||
}
|
||||
|
||||
export function trackErrorHandlerCrash(
|
||||
|
||||
@@ -37,6 +37,34 @@ source_ref?: string
|
||||
source_ref_hashed?: boolean
|
||||
}
|
||||
|
||||
export interface PaperclipConnectionCreatedDimensions {
|
||||
auth_kind: ("oauth" | "api_key" | "none")
|
||||
connector_key: string
|
||||
enabled: boolean
|
||||
setup_flow: ("gallery" | "api" | "example")
|
||||
status: ("draft" | "active" | "disabled" | "archived")
|
||||
transport: ("mcp_remote" | "rest_api" | "local_stdio")
|
||||
}
|
||||
|
||||
export interface PaperclipConnectionInvokedDimensions {
|
||||
connector_key: string
|
||||
duration_seconds?: number
|
||||
origin: ("setup_test" | "agent" | "user" | "system" | "plugin")
|
||||
status: ("succeeded" | "failed" | "denied" | "cancelled" | "timed_out" | "rate_limited")
|
||||
transport: ("mcp_remote" | "rest_api" | "local_stdio")
|
||||
}
|
||||
|
||||
export interface PaperclipConnectionUpdatedDimensions {
|
||||
auth_kind: ("oauth" | "api_key" | "none")
|
||||
change_source: ("api" | "gallery" | "oauth_callback" | "credential_refresh" | "archive" | "example")
|
||||
connector_key: string
|
||||
enabled: boolean
|
||||
previous_enabled: boolean
|
||||
previous_status: ("draft" | "active" | "disabled" | "archived")
|
||||
status: ("draft" | "active" | "disabled" | "archived")
|
||||
transport: ("mcp_remote" | "rest_api" | "local_stdio")
|
||||
}
|
||||
|
||||
export interface PaperclipErrorHandlerCrashDimensions {
|
||||
error_code: string
|
||||
}
|
||||
@@ -58,7 +86,6 @@ interaction_kind: ("suggest_tasks" | "ask_user_questions" | "request_confirmatio
|
||||
used_deprecated_resolver_policy_alias: boolean
|
||||
}
|
||||
|
||||
/** Emit only after the complete resolution transaction commits, including response provenance. */
|
||||
export interface PaperclipInteractionResolvedDimensions {
|
||||
interaction_kind: ("suggest_tasks" | "ask_user_questions" | "request_confirmation" | "request_checkbox_confirmation" | "request_item_verdicts" | "other")
|
||||
status: ("accepted" | "rejected" | "answered" | "cancelled" | "expired" | "failed" | "other")
|
||||
@@ -95,9 +122,11 @@ source: ("schedule" | "manual" | "api" | "webhook" | "other")
|
||||
status: ("received" | "coalesced" | "skipped" | "issue_created" | "completed" | "failed" | "other")
|
||||
}
|
||||
|
||||
export interface PaperclipServerStartedDimensions {
|
||||
os_platform: ("aix" | "android" | "darwin" | "freebsd" | "haiku" | "linux" | "openbsd" | "sunos" | "win32" | "cygwin" | "netbsd" | "other")
|
||||
}
|
||||
|
||||
export interface PaperclipSkillImportedDimensions {
|
||||
/** skill_ref may identify a known public catalog source only. Omit it for
|
||||
* GitHub-synced sources, which may be private; never emit repository paths or names. */
|
||||
source_type: ("local_path" | "github" | "url" | "catalog" | "skills_sh" | "unknown")
|
||||
skill_ref?: string
|
||||
}
|
||||
@@ -108,6 +137,9 @@ export type PaperclipEventName =
|
||||
| "agent.task_completed"
|
||||
| "agent.task_run"
|
||||
| "company.imported"
|
||||
| "connection.created"
|
||||
| "connection.invoked"
|
||||
| "connection.updated"
|
||||
| "error.handler_crash"
|
||||
| "goal.created"
|
||||
| "install.completed"
|
||||
@@ -117,6 +149,7 @@ export type PaperclipEventName =
|
||||
| "project.created"
|
||||
| "routine.created"
|
||||
| "routine.run"
|
||||
| "server.started"
|
||||
| "skill.imported";
|
||||
|
||||
export interface EventDimensionsMap {
|
||||
@@ -125,6 +158,9 @@ export interface EventDimensionsMap {
|
||||
"agent.task_completed": PaperclipAgentTaskCompletedDimensions;
|
||||
"agent.task_run": PaperclipAgentTaskRunDimensions;
|
||||
"company.imported": PaperclipCompanyImportedDimensions;
|
||||
"connection.created": PaperclipConnectionCreatedDimensions;
|
||||
"connection.invoked": PaperclipConnectionInvokedDimensions;
|
||||
"connection.updated": PaperclipConnectionUpdatedDimensions;
|
||||
"error.handler_crash": PaperclipErrorHandlerCrashDimensions;
|
||||
"goal.created": PaperclipGoalCreatedDimensions;
|
||||
"install.completed": PaperclipInstallCompletedDimensions;
|
||||
@@ -134,6 +170,7 @@ export interface EventDimensionsMap {
|
||||
"project.created": PaperclipProjectCreatedDimensions;
|
||||
"routine.created": PaperclipRoutineCreatedDimensions;
|
||||
"routine.run": PaperclipRoutineRunDimensions;
|
||||
"server.started": PaperclipServerStartedDimensions;
|
||||
"skill.imported": PaperclipSkillImportedDimensions;
|
||||
}
|
||||
|
||||
@@ -143,6 +180,9 @@ export const PAPERCLIP_EVENTS = {
|
||||
"agent.task_completed": "agent.task_completed",
|
||||
"agent.task_run": "agent.task_run",
|
||||
"company.imported": "company.imported",
|
||||
"connection.created": "connection.created",
|
||||
"connection.invoked": "connection.invoked",
|
||||
"connection.updated": "connection.updated",
|
||||
"error.handler_crash": "error.handler_crash",
|
||||
"goal.created": "goal.created",
|
||||
"install.completed": "install.completed",
|
||||
@@ -152,6 +192,7 @@ export const PAPERCLIP_EVENTS = {
|
||||
"project.created": "project.created",
|
||||
"routine.created": "routine.created",
|
||||
"routine.run": "routine.run",
|
||||
"server.started": "server.started",
|
||||
"skill.imported": "skill.imported",
|
||||
} as const;
|
||||
|
||||
@@ -277,6 +318,83 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = {
|
||||
"unknown": "Source type could not be classified."
|
||||
}
|
||||
},
|
||||
"connection.created": {
|
||||
"auth_kind": {
|
||||
"oauth": "Connection authenticates with an OAuth flow.",
|
||||
"api_key": "Connection authenticates with a stored API key.",
|
||||
"none": "Connection requires no authentication."
|
||||
},
|
||||
"setup_flow": {
|
||||
"gallery": "Connection was created through the app-gallery setup flow.",
|
||||
"api": "Connection was created through the connections API.",
|
||||
"example": "Connection was created or upserted as a bundled example connection."
|
||||
},
|
||||
"status": {
|
||||
"draft": "Connection row exists but setup has not produced a usable configuration yet.",
|
||||
"active": "Connection is fully configured and available for use.",
|
||||
"disabled": "Connection is disabled and excluded from tool use.",
|
||||
"archived": "Connection is archived and excluded from tool use."
|
||||
},
|
||||
"transport": {
|
||||
"mcp_remote": "Connection talks to a remote MCP server over HTTP.",
|
||||
"rest_api": "Connection calls the provider's REST API directly.",
|
||||
"local_stdio": "Connection runs a local MCP server over stdio."
|
||||
}
|
||||
},
|
||||
"connection.invoked": {
|
||||
"origin": {
|
||||
"setup_test": "A human ran the connection's setup test, identified by durable invocation columns: user actor with no run, issue, or gateway reference and a connection id.",
|
||||
"agent": "An agent run invoked the tool.",
|
||||
"user": "A human invoked the tool outside the setup-test shape.",
|
||||
"system": "A system-initiated process invoked the tool.",
|
||||
"plugin": "A plugin invoked the tool."
|
||||
},
|
||||
"status": {
|
||||
"succeeded": "The tool invocation completed successfully.",
|
||||
"failed": "The tool invocation reached a terminal failure.",
|
||||
"denied": "The tool invocation was denied by policy or by a human approval decision.",
|
||||
"cancelled": "The tool invocation was cancelled before completion. Declared in the application status enum; no current application code path writes this value.",
|
||||
"timed_out": "The tool invocation exceeded its time limit.",
|
||||
"rate_limited": "The tool invocation was rejected by rate limiting."
|
||||
},
|
||||
"transport": {
|
||||
"mcp_remote": "Connection talks to a remote MCP server over HTTP.",
|
||||
"rest_api": "Connection calls the provider's REST API directly.",
|
||||
"local_stdio": "Connection runs a local MCP server over stdio."
|
||||
}
|
||||
},
|
||||
"connection.updated": {
|
||||
"auth_kind": {
|
||||
"oauth": "Connection authenticates with an OAuth flow.",
|
||||
"api_key": "Connection authenticates with a stored API key.",
|
||||
"none": "Connection requires no authentication."
|
||||
},
|
||||
"change_source": {
|
||||
"api": "A connections API update changed the persisted lifecycle state.",
|
||||
"gallery": "An app-gallery setup step (finish, OAuth access finalization, or reconnect; Apps gallery and inline task cards) changed the persisted lifecycle state.",
|
||||
"oauth_callback": "An OAuth callback completion (provider OAuth, Paperclip Cloud connector, or Vercel Connect) changed the persisted lifecycle state.",
|
||||
"credential_refresh": "A credential refresh changed the persisted lifecycle state.",
|
||||
"archive": "An archive operation changed the persisted lifecycle state.",
|
||||
"example": "An example-connection upsert changed the persisted lifecycle state."
|
||||
},
|
||||
"previous_status": {
|
||||
"draft": "Connection row exists but setup has not produced a usable configuration yet.",
|
||||
"active": "Connection is fully configured and available for use.",
|
||||
"disabled": "Connection is disabled and excluded from tool use.",
|
||||
"archived": "Connection is archived and excluded from tool use."
|
||||
},
|
||||
"status": {
|
||||
"draft": "Connection row exists but setup has not produced a usable configuration yet.",
|
||||
"active": "Connection is fully configured and available for use.",
|
||||
"disabled": "Connection is disabled and excluded from tool use.",
|
||||
"archived": "Connection is archived and excluded from tool use."
|
||||
},
|
||||
"transport": {
|
||||
"mcp_remote": "Connection talks to a remote MCP server over HTTP.",
|
||||
"rest_api": "Connection calls the provider's REST API directly.",
|
||||
"local_stdio": "Connection runs a local MCP server over stdio."
|
||||
}
|
||||
},
|
||||
"goal.created": {
|
||||
"goal_level": {
|
||||
"company": "Goal applies at company scope.",
|
||||
@@ -402,6 +520,22 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = {
|
||||
"other": "Fallback when the status is unknown or not represented by the tracked enum."
|
||||
}
|
||||
},
|
||||
"server.started": {
|
||||
"os_platform": {
|
||||
"aix": "Server host runs IBM AIX.",
|
||||
"android": "Server host runs Android.",
|
||||
"darwin": "Server host runs macOS.",
|
||||
"freebsd": "Server host runs FreeBSD.",
|
||||
"haiku": "Server host runs Haiku.",
|
||||
"linux": "Server host runs Linux, including WSL and container hosts.",
|
||||
"openbsd": "Server host runs OpenBSD.",
|
||||
"sunos": "Server host runs SunOS, Solaris, or illumos.",
|
||||
"win32": "Server host runs Windows, both 32-bit and 64-bit.",
|
||||
"cygwin": "Server host runs under Cygwin on Windows.",
|
||||
"netbsd": "Server host runs NetBSD.",
|
||||
"other": "Fallback when the platform is unknown or not represented by the tracked enum."
|
||||
}
|
||||
},
|
||||
"skill.imported": {
|
||||
"source_type": {
|
||||
"local_path": "Import source came from a filesystem path on the operator's machine.",
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
trackConnectionCreated,
|
||||
trackConnectionUpdated,
|
||||
trackConnectionInvoked,
|
||||
trackServerStarted,
|
||||
} from "./events.js";
|
||||
export type {
|
||||
TelemetryConfig,
|
||||
|
||||
@@ -47,4 +47,5 @@ export const EVENT_RETENTION_CLASS: Partial<Record<string, RetentionClass>> = {
|
||||
"codex.credential_health": "operational_enum_count",
|
||||
"interaction.created": "operational_enum_count",
|
||||
"interaction.resolved": "operational_enum_count",
|
||||
"server.started": "operational_enum_count",
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TelemetryClient } from "./client.js";
|
||||
import { trackServerStarted } from "./events.js";
|
||||
import type { TelemetryState } from "./types.js";
|
||||
|
||||
const TEST_STATE: TelemetryState = {
|
||||
installId: "test-install",
|
||||
salt: "test-salt",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
firstSeenVersion: "0.0.0",
|
||||
};
|
||||
|
||||
function makeClient(enabled = true) {
|
||||
const stateFactory = vi.fn(() => TEST_STATE);
|
||||
return {
|
||||
client: new TelemetryClient(
|
||||
{ enabled, endpoint: "http://localhost:9999/ingest" },
|
||||
stateFactory,
|
||||
"0.0.0-test",
|
||||
() => 0.5,
|
||||
),
|
||||
stateFactory,
|
||||
};
|
||||
}
|
||||
|
||||
describe("registered server.started event", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("passes the raw process.platform value through without normalization", () => {
|
||||
const { client } = makeClient();
|
||||
const track = vi.spyOn(client, "track");
|
||||
|
||||
trackServerStarted(client, { os_platform: "win32" });
|
||||
trackServerStarted(client, { os_platform: "some-future-platform" });
|
||||
|
||||
expect(track.mock.calls).toEqual([
|
||||
["server.started", { os_platform: "win32" }],
|
||||
["server.started", { os_platform: "some-future-platform" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the registered event with only the raw OS family", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
||||
const { client, stateFactory } = makeClient();
|
||||
|
||||
expect(client.isRegisteredEventName("server.started")).toBe(true);
|
||||
trackServerStarted(client, { os_platform: process.platform });
|
||||
await client.flush();
|
||||
|
||||
expect(stateFactory).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0]?.[1]?.body));
|
||||
expect(body.events).toEqual([
|
||||
{ name: "server.started", occurredAt: expect.any(String), dimensions: { os_platform: process.platform } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves state and network untouched when disabled", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
const { client, stateFactory } = makeClient(false);
|
||||
trackServerStarted(client, { os_platform: process.platform });
|
||||
await client.flush();
|
||||
expect(stateFactory).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,39 @@ test("Docker Hub mirrors apply to both image builders without widening publicati
|
||||
"the registry-only manifest merge does not need a Docker Hub mirror");
|
||||
});
|
||||
|
||||
test("source proof gates publication without occupying a publication runner", () => {
|
||||
const workflow = readWorkflow("docker.yml");
|
||||
const observer = workflow.split(" source_verification:\n")[1].split(" reuse_canary_image:")[0];
|
||||
const gate = workflow.split(" publication_verification:\n")[1].split(" merge-and-push:")[0];
|
||||
const publisher = workflow.split(" merge-and-push:\n")[1].split(" promote_canary_channel:")[0];
|
||||
assert.match(observer, /verified: \$\{\{ steps.watch.outputs.verified \}\}/);
|
||||
assert.match(gate, /run: node scripts\/watch-release-source.mjs --require-proof/);
|
||||
assert.match(gate, /actions: write # restart only exact-source producers deferred before admission/);
|
||||
assert.match(gate, /needs: \[build-and-push, source_verification\]/);
|
||||
assert.match(publisher, /needs: \[build-and-push, source_verification, publication_verification\]/);
|
||||
assert.doesNotMatch(publisher, /--require-proof|actions: write/);
|
||||
assert.ok(publisher.indexOf("run: node scripts/watch-release-source.mjs --check-proof") < publisher.indexOf("- name: Create manifest list and push"));
|
||||
assert.match(publisher, /actions: read/);
|
||||
assert.match(publisher, /timeout-minutes: 20/);
|
||||
const expression = publisher.match(/^ if: \$\{\{ (.+) \}\}$/m)[1].replaceAll("needs.build-and-push", "needs.build");
|
||||
for (const [ref, observed, fallback, expected, repository = "paperclipai/paperclip", repository_id = "1170821064"] of [
|
||||
["refs/heads/master", "true", "skipped", true],
|
||||
["refs/heads/master", "false", "skipped", false],
|
||||
["refs/heads/master", undefined, "skipped", false],
|
||||
["refs/heads/master", "false", "success", true],
|
||||
["refs/heads/master", "false", "failure", false],
|
||||
["refs/tags/v1", undefined, "skipped", true],
|
||||
["refs/heads/master", undefined, "skipped", true, "fork/paperclip", "123"],
|
||||
["refs/heads/master", undefined, "skipped", true, "paperclipai/paperclip", "123"],
|
||||
]) {
|
||||
const canonical = repository === "paperclipai/paperclip" && repository_id === "1170821064" && ref === "refs/heads/master";
|
||||
assert.equal(runInNewContext(expression, { cancelled: () => false, github: { ref, repository, repository_id }, needs: {
|
||||
build: { result: "success" }, source_verification: { result: canonical ? "success" : "skipped", outputs: { verified: observed } },
|
||||
publication_verification: { result: fallback },
|
||||
} }), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("chaos verification isolates callers that verify the same source commit", () => {
|
||||
const chaosWorkflow = readWorkflow("runner-chaos-evals.yml");
|
||||
const group = chaosWorkflow.match(/^ group: (.+)$/m)?.[1];
|
||||
@@ -82,11 +115,11 @@ test("chaos verification isolates callers that verify the same source commit", (
|
||||
assert.match(chaosWorkflow, /cancel-in-progress: true/);
|
||||
});
|
||||
|
||||
test("canary reuses exact-source proof while stable keeps full verification", () => {
|
||||
test("canary can admit deferred exact-source checks while stable keeps full verification", () => {
|
||||
const releaseWorkflow = readWorkflow("release.yml");
|
||||
const canary = releaseWorkflow.match(/ verify_canary:\n[\s\S]*?(?=\n [a-z_]+:)/)[0];
|
||||
assert.match(canary, /github\.repository == 'paperclipai\/paperclip' && github\.event_name == 'push' && github\.ref == 'refs\/heads\/master'/);
|
||||
assert.match(canary, /actions: read/);
|
||||
assert.match(canary, /github\.repository == 'paperclipai\/paperclip' && github\.repository_id == '1170821064' && github\.event_name == 'push' && github\.ref == 'refs\/heads\/master'/);
|
||||
assert.match(canary, /actions: write # restart only an exact-source push deferred before admission/);
|
||||
assert.match(canary, /ref: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(canary, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(canary, /run: node scripts\/cloud-source-verification\.mjs "\$SOURCE_SHA"/);
|
||||
@@ -933,7 +966,9 @@ test('native dependency caches are target-bound and unmerged master callers cann
|
||||
const cache = producer.split(' - name: Cache native Rust dependencies\n')[1].split('\n - ')[0];
|
||||
assert.match(cache, /Swatinem\/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6/);
|
||||
assert.match(cache, /workspaces: \$\{\{ steps.native_rust_workspace.outputs.path \}\} -> target/);
|
||||
assert.match(cache, /shared-key: release-native-v2-\$\{\{ matrix.host \}\}-\$\{\{ matrix.target \}\}-\$\{\{ hashFiles\('packages\/paperclip-runner\/protocol\/\*\*', 'packages\/paperclip-runner\/src\/\*\*', 'packages\/paperclip-runner\/\*.json'\) \}\}/);
|
||||
const key = cache.match(/^\s*shared-key: (.+)$/m)?.[1];
|
||||
assert.ok(key?.startsWith("release-native-v2-${{ hashFiles('packages/paperclip-runner/runner/Cargo.toml', "));
|
||||
assert.ok(key?.endsWith("-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/src/**', 'packages/paperclip-runner/*.json') }}"));
|
||||
for (const option of ['cache-workspace-crates', 'cache-bin', 'cache-on-failure']) {
|
||||
assert.match(cache, new RegExp(option + ': false'));
|
||||
}
|
||||
|
||||
@@ -7,11 +7,19 @@ import { readPluginsUnder } from "./link-plugin-dev-sdk.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const postinstallHooks = new Map([
|
||||
[".", "node scripts/link-plugin-dev-sdk.mjs"],
|
||||
["server", "node scripts/install-copilot-assets.mjs"],
|
||||
["packages/paperclip-runner", "node scripts/install-copilot-assets.mjs"],
|
||||
]);
|
||||
|
||||
export function cachePlan({ files, read, scope, packageDirs }) {
|
||||
const dependencyInputs = files.filter((file) =>
|
||||
/(^|\/)package\.json$/.test(file)
|
||||
|| ["pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ".pnpmfile.cjs", "pnpmfile.cjs"].includes(file)
|
||||
|| file.startsWith("scripts/")
|
||||
|| file.startsWith("server/scripts/")
|
||||
|| file.startsWith("packages/paperclip-runner/scripts/")
|
||||
|| file.startsWith("patches/"));
|
||||
const digest = (inputs, salt) => {
|
||||
const hash = createHash("sha256").update(JSON.stringify(salt));
|
||||
@@ -28,8 +36,8 @@ export function cachePlan({ files, read, scope, packageDirs }) {
|
||||
if (manifest === null) return false;
|
||||
const scripts = JSON.parse(manifest).scripts ?? {};
|
||||
return ['preinstall', 'install', 'postinstall', 'prepare'].every((hook) =>
|
||||
scripts[hook] === undefined || (directory === '.' && hook === 'postinstall'
|
||||
&& scripts[hook] === 'node scripts/link-plugin-dev-sdk.mjs'));
|
||||
scripts[hook] === undefined || (hook === 'postinstall'
|
||||
&& scripts[hook] === postinstallHooks.get(directory)));
|
||||
});
|
||||
const buildInputs = files.filter((file) =>
|
||||
file.startsWith("packages/shared/") || file.startsWith("packages/plugins/sdk/")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createActionsReader } from "./github-actions-reader.mjs";
|
||||
|
||||
const repository = "paperclipai/paperclip";
|
||||
const repositoryId = 1170821064;
|
||||
const workflows = new Set(["docker.yml", "release.yml", "cloud-readiness.yml", "cloud-migrator-artifacts.yml"]);
|
||||
export const sourceAdmissionJob = "Cloud candidate admission v1";
|
||||
export const migratorAdmissionJob = "Cloud image candidate admission v1";
|
||||
|
||||
function assertSha(sha) {
|
||||
if (!/^[a-f0-9]{40}$/.test(sha ?? "")) throw new Error("Admission requires a full source SHA.");
|
||||
}
|
||||
|
||||
function trusted(run, sha, workflow) {
|
||||
return run.workflow_id === workflow.id && run.path === workflow.path && run.head_sha === sha &&
|
||||
run.repository?.id === repositoryId && run.head_repository?.id === repositoryId &&
|
||||
run.repository?.full_name === repository && run.head_repository?.full_name === repository &&
|
||||
run.head_branch === "master" && run.event === "push" &&
|
||||
Number.isSafeInteger(run.id) && run.id > 0 && Number.isSafeInteger(run.run_attempt) && run.run_attempt > 0;
|
||||
}
|
||||
|
||||
async function producer(file, sha, api, status) {
|
||||
assertSha(sha);
|
||||
if (!workflows.has(file)) throw new Error("Unknown release workflow.");
|
||||
const workflow = await api(`/repos/${repository}/actions/workflows/${file}`);
|
||||
if (workflow.path !== `.github/workflows/${file}` || !Number.isSafeInteger(workflow.id) || workflow.id < 1) throw new Error("Release workflow identity mismatch.");
|
||||
const list = await api(`/repos/${repository}/actions/workflows/${workflow.id}/runs?branch=master&event=push&head_sha=${sha}&per_page=100${status ? `&status=${status}` : ""}`);
|
||||
if (!Array.isArray(list.workflow_runs) || !Number.isSafeInteger(list.total_count) || list.total_count < 0 ||
|
||||
list.total_count > 100 || list.workflow_runs.length !== list.total_count) throw new Error("Incomplete release workflow listing.");
|
||||
const run = list.workflow_runs.filter(run => trusted(run, sha, workflow) && (!status || run.status === status)).sort((a, b) => b.id - a.id)[0];
|
||||
return { workflow, run };
|
||||
}
|
||||
|
||||
// A retry is an explicit request for this exact immutable source. Normal push
|
||||
// work starts only for a candidate admitted by an image build or npm canary.
|
||||
export async function releaseAdmitted(sha, { api, event = "push", attempt = 1, imageOnly = false }) {
|
||||
assertSha(sha);
|
||||
if (!Number.isSafeInteger(attempt) || attempt < 1) throw new Error("Invalid release attempt.");
|
||||
if (!["push", "workflow_dispatch"].includes(event)) return false;
|
||||
if (event === "workflow_dispatch" || (Number.isSafeInteger(attempt) && attempt > 1)) return true;
|
||||
for (const file of imageOnly ? ["docker.yml"] : ["docker.yml", "release.yml"]) {
|
||||
const { workflow, run } = await producer(file, sha, api, "in_progress");
|
||||
if (!run) continue;
|
||||
const current = await api(`/repos/${repository}/actions/runs/${run.id}`);
|
||||
if (current.id === run.id && trusted(current, sha, workflow) && current.run_attempt === run.run_attempt && current.status === "in_progress") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function deferredProducer(jobs, run, admissionJob) {
|
||||
const admission = jobs.filter(job => job.name === admissionJob);
|
||||
const remaining = jobs.filter(job => job.name !== admissionJob);
|
||||
return run.status === "completed" && run.conclusion === "success" && admission.length === 1 && admission[0].status === "completed" && admission[0].conclusion === "success" && remaining.length > 0 &&
|
||||
remaining.every(job => job.status === "completed" && job.conclusion === "skipped") &&
|
||||
jobs.every(job => job.run_id === run.id && job.run_attempt === run.run_attempt && job.head_sha === run.head_sha);
|
||||
}
|
||||
|
||||
// Only skipped admission is restartable here. A failed check or build never
|
||||
// becomes an automatic retry, and every write revalidates canonical identity.
|
||||
export async function requestDeferredProducer({ file, sha, admissionJob, api, rerun, requested = new Set() }) {
|
||||
if (({ "cloud-readiness.yml": sourceAdmissionJob, "cloud-migrator-artifacts.yml": migratorAdmissionJob })[file] !== admissionJob) throw new Error("Unknown deferred producer contract.");
|
||||
const { workflow, run } = await producer(file, sha, api);
|
||||
if (!run) return "pending";
|
||||
const key = `${run.id}/${run.run_attempt}`;
|
||||
if (requested.has(key)) return "pending";
|
||||
const jobs = [];
|
||||
let total;
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const batch = await api(`/repos/${repository}/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100&page=${page}`);
|
||||
if (!Array.isArray(batch.jobs) || !Number.isSafeInteger(batch.total_count) || batch.total_count < 0) throw new Error("Invalid release producer job list.");
|
||||
if ((total !== undefined && total !== batch.total_count) ||
|
||||
batch.jobs.length !== Math.min(100, Math.max(0, batch.total_count - (page - 1) * 100))) throw new Error("Incomplete release producer job list.");
|
||||
total = batch.total_count;
|
||||
jobs.push(...batch.jobs);
|
||||
if (page * 100 >= total) break;
|
||||
if (page === 10) throw new Error("Incomplete release producer job list.");
|
||||
}
|
||||
if (run.status !== "completed") {
|
||||
const current = jobs.filter(job => job.run_id === run.id && job.run_attempt === run.run_attempt && job.head_sha === sha);
|
||||
// Admission itself succeeds for both outcomes. Only an actual build is
|
||||
// evidence that its output admitted work; queued jobs may still be skipped.
|
||||
return current.some(job => job.name !== admissionJob && job.labels?.length > 0 && ["in_progress", "completed"].includes(job.status) && job.conclusion !== "skipped")
|
||||
? "admitted" : "pending";
|
||||
}
|
||||
if (!deferredProducer(jobs, run, admissionJob)) return "finished";
|
||||
const current = await api(`/repos/${repository}/actions/runs/${run.id}`);
|
||||
if (current.id !== run.id || !trusted(current, sha, workflow) || current.run_attempt !== run.run_attempt || current.status !== "completed" || current.conclusion !== "success") return "pending";
|
||||
try {
|
||||
await rerun(`/repos/${repository}/actions/runs/${run.id}/rerun`);
|
||||
} catch (error) {
|
||||
// Docker and npm may select the same source at the same time. A rejected
|
||||
// duplicate write is harmless only when a fresh read proves a retry began.
|
||||
const latest = await api(`/repos/${repository}/actions/runs/${run.id}`);
|
||||
if (latest.id !== run.id || !trusted(latest, sha, workflow)) throw error;
|
||||
if (!(latest.run_attempt > run.run_attempt || (latest.run_attempt === run.run_attempt &&
|
||||
["queued", "in_progress", "requested", "pending", "waiting"].includes(latest.status)))) {
|
||||
// GitHub may reject a duplicate before its read API shows the retry.
|
||||
// Leave it pending for the next poll; this is not proof of admission.
|
||||
if ([409, 422].includes(error.status) && latest.run_attempt === run.run_attempt && latest.status === "completed" && latest.conclusion === "success") return "pending";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
requested.add(key);
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function createReleaseRerunner(token, fetchImpl = fetch) {
|
||||
return async path => {
|
||||
if (!/^\/repos\/paperclipai\/paperclip\/actions\/runs\/[1-9]\d*\/rerun$/.test(path)) throw new Error("Invalid release retry path.");
|
||||
const response = await fetchImpl(`https://api.github.com${path}`, { method: "POST", redirect: "error",
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, signal: AbortSignal.timeout(30_000) });
|
||||
if (!response.ok) throw Object.assign(new Error(`Could not admit deferred release work: HTTP ${response.status}.`), { status: response.status });
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (process.env.GITHUB_REPOSITORY !== repository || process.env.GITHUB_REF !== "refs/heads/master" || !process.env.GITHUB_TOKEN) throw new Error("Canonical master admission context required.");
|
||||
const admitted = await releaseAdmitted(process.env.GITHUB_SHA, { api: createActionsReader({ token: process.env.GITHUB_TOKEN }),
|
||||
event: process.env.GITHUB_EVENT_NAME, attempt: Number(process.env.GITHUB_RUN_ATTEMPT), imageOnly: process.argv[2] === "image-only" });
|
||||
console.log(admitted ? "Release candidate admitted." : "Full build deferred until a release consumer admits this source.");
|
||||
if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `admitted=${admitted}\n`);
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createActionsReader } from "./github-actions-reader.mjs";
|
||||
import { deferredProducer, requestDeferredProducer, sourceAdmissionJob, createReleaseRerunner } from "./cloud-release-admission.mjs";
|
||||
|
||||
const repository = "paperclipai/paperclip";
|
||||
const workflowPath = ".github/workflows/cloud-readiness.yml";
|
||||
export const sourceVerificationJob = "Cloud source verified v1";
|
||||
export class SourceVerificationFailed extends Error {
|
||||
constructor(message, run) {
|
||||
super(message);
|
||||
this.runId = run.id;
|
||||
this.attempt = run.run_attempt;
|
||||
}
|
||||
}
|
||||
export class SourceVerificationDeferred extends Error {}
|
||||
|
||||
function assertSha(sha) {
|
||||
if (!/^[a-f0-9]{40}$/.test(sha ?? "")) throw new Error("A full lowercase source SHA is required.");
|
||||
@@ -19,7 +29,7 @@ function trustedRun(run, sha, workflowId) {
|
||||
|
||||
// Consume one versioned job, independent of image/migrator availability. A
|
||||
// failed image build must not invalidate source checks that already passed.
|
||||
export async function readSourceVerification(sha, api) {
|
||||
export async function readSourceVerification(sha, api, { failFast = false, onJobs } = {}) {
|
||||
assertSha(sha);
|
||||
const workflow = await api(`/repos/${repository}/actions/workflows/cloud-readiness.yml`);
|
||||
if (workflow.path !== workflowPath || !Number.isSafeInteger(workflow.id) || workflow.id < 1) {
|
||||
@@ -46,6 +56,17 @@ export async function readSourceVerification(sha, api) {
|
||||
}
|
||||
const matches = jobs.filter((job) => job.name === sourceVerificationJob);
|
||||
if (matches.length > 1) throw new Error("Cloud source verification job is ambiguous.");
|
||||
await onJobs?.(jobs.filter(job => job.head_sha === sha && job.run_id === run.id && job.run_attempt === run.run_attempt));
|
||||
if (failFast) {
|
||||
const failure = jobs.find((job) => job.status === "completed" &&
|
||||
["failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"].includes(job.conclusion) &&
|
||||
job.head_sha === sha && job.run_id === run.id && job.run_attempt === run.run_attempt);
|
||||
if (failure) {
|
||||
const current = await api(`/repos/${repository}/actions/runs/${run.id}`);
|
||||
if (!trustedRun(current, sha, workflow.id) || current.run_attempt !== run.run_attempt) return undefined;
|
||||
throw new SourceVerificationFailed(`Cloud source verification failed: ${failure.name} (${failure.conclusion}).`, run);
|
||||
}
|
||||
}
|
||||
const job = matches[0];
|
||||
if (job?.status === "completed" && job.conclusion === "success" &&
|
||||
job.head_sha === sha && job.run_id === run.id && job.run_attempt === run.run_attempt) {
|
||||
@@ -56,95 +77,33 @@ export async function readSourceVerification(sha, api) {
|
||||
return { sha, runId: run.id, attempt: run.run_attempt, jobId: job.id };
|
||||
}
|
||||
if (job?.status === "completed" || run.status === "completed") {
|
||||
throw new Error(`Cloud source verification did not pass for ${sha} (run ${run.id}, attempt ${run.run_attempt}). Rerun Cloud readiness before retrying the release.`);
|
||||
const current = await api(`/repos/${repository}/actions/runs/${run.id}`);
|
||||
if (!trustedRun(current, sha, workflow.id) || current.run_attempt !== run.run_attempt) return undefined;
|
||||
if (job?.conclusion === "skipped" && current.status !== "completed") return undefined;
|
||||
if (deferredProducer(jobs, current, sourceAdmissionJob)) throw new SourceVerificationDeferred("Source checks are deferred until release admission.");
|
||||
throw new SourceVerificationFailed(`Cloud source verification did not pass for ${sha} (run ${run.id}, attempt ${run.run_attempt}). Rerun Cloud readiness before retrying the release.`, run);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Statuses that say "ask again", not "the answer is no". A release must not be
|
||||
// blocked because GitHub returned a gateway error during a 45-minute poll.
|
||||
const TRANSIENT_READ_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
|
||||
// A rate-limited read also says "ask again", and GitHub reports both primary
|
||||
// and secondary rate limits as 403. Only the headers separate that from a
|
||||
// token that may not read Actions, which must still fail at once.
|
||||
function rateLimited(response) {
|
||||
if (response.status !== 403) return false;
|
||||
const header = (name) => response.headers?.get?.(name) ?? null;
|
||||
return header("retry-after") !== null || header("x-ratelimit-remaining") === "0";
|
||||
}
|
||||
|
||||
/** Milliseconds from a Retry-After header, when it carries a sane delay. */
|
||||
function retryAfterMs(response, capMs) {
|
||||
const value = Number(response?.headers?.get?.("retry-after"));
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.min(value * 1_000, capMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* The GitHub Actions read used by the polling below, with transient transport
|
||||
* failures retried: network errors, the statuses above, rate-limited 403s, and
|
||||
* a body that fails while it is being read. Any other non-OK status throws on
|
||||
* the first response, because waiting out a wrong token only delays the news.
|
||||
*
|
||||
* `deadlineAt` bounds retries by the caller's own polling deadline, so a read
|
||||
* cannot extend the wait past the timeout it belongs to.
|
||||
*/
|
||||
export function createActionsReader({
|
||||
token, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
attempts = 4, backoffMs = 1_000, log = console.log,
|
||||
now = Date.now, deadlineAt = () => Infinity, maxRetryAfterMs = 60_000,
|
||||
} = {}) {
|
||||
return async (path) => {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
// Retry only while both the attempt budget and the caller's deadline
|
||||
// leave room for the wait this attempt would cost.
|
||||
const waitMs = backoffMs * attempt;
|
||||
const retryable = attempt < attempts && now() + waitMs < deadlineAt();
|
||||
const pause = (response) => sleep(retryAfterMs(response, maxRetryAfterMs) ?? waitMs);
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(`https://api.github.com${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
||||
signal: AbortSignal.timeout(30_000), redirect: "error",
|
||||
});
|
||||
} catch (cause) {
|
||||
if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`);
|
||||
log(`GitHub Actions read failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`);
|
||||
await pause();
|
||||
continue;
|
||||
}
|
||||
if (response.ok) {
|
||||
try {
|
||||
// A 200 whose body is truncated or undecodable is a transport
|
||||
// failure like any other, so it belongs inside the retry.
|
||||
return await response.json();
|
||||
} catch (cause) {
|
||||
if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`);
|
||||
log(`GitHub Actions read body failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`);
|
||||
await pause();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!retryable || !(TRANSIENT_READ_STATUSES.has(response.status) || rateLimited(response))) {
|
||||
throw new Error(`GitHub Actions read failed (HTTP ${response.status}).`);
|
||||
}
|
||||
log(`GitHub Actions read failed (HTTP ${response.status}); retrying (${attempt}/${attempts - 1}).`);
|
||||
await pause(response);
|
||||
}
|
||||
};
|
||||
}
|
||||
export { createActionsReader };
|
||||
|
||||
export async function waitForSourceVerification(sha, {
|
||||
api, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
timeoutMs = 45 * 60_000, intervalMs = 30_000, log = console.log,
|
||||
timeoutMs = 60 * 60_000, intervalMs = 30_000, log = console.log, rerun,
|
||||
} = {}) {
|
||||
assertSha(sha);
|
||||
const deadline = now() + timeoutMs;
|
||||
const requested = new Set();
|
||||
log(`Waiting for ${sourceVerificationJob} for ${sha}.`);
|
||||
while (now() < deadline) {
|
||||
const result = await readSourceVerification(sha, api);
|
||||
if (result) return result;
|
||||
try {
|
||||
const result = await readSourceVerification(sha, api);
|
||||
if (result) return result;
|
||||
} catch (error) {
|
||||
if (!(error instanceof SourceVerificationDeferred) || !rerun) throw error;
|
||||
await requestDeferredProducer({ file: "cloud-readiness.yml", sha, admissionJob: sourceAdmissionJob, api, rerun, requested });
|
||||
}
|
||||
const remaining = deadline - now();
|
||||
if (remaining > 0) await sleep(Math.min(intervalMs, remaining));
|
||||
}
|
||||
@@ -156,10 +115,11 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
if (!process.env.GITHUB_TOKEN) throw new Error("GITHUB_TOKEN with Actions read access is required.");
|
||||
// One deadline for both layers: the reader stops retrying when the poll it
|
||||
// serves is out of time, instead of extending the wait past its timeout.
|
||||
const timeoutMs = 45 * 60_000;
|
||||
const timeoutMs = 60 * 60_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const api = createActionsReader({ token: process.env.GITHUB_TOKEN, deadlineAt: () => deadline });
|
||||
const proof = await waitForSourceVerification(process.argv[2], { api, timeoutMs });
|
||||
const rerun = process.env.GITHUB_REPOSITORY === repository && process.env.GITHUB_REF === "refs/heads/master" ? createReleaseRerunner(process.env.GITHUB_TOKEN) : undefined;
|
||||
const proof = await waitForSourceVerification(process.argv[2], { api, timeoutMs, rerun });
|
||||
const message = `Source verification passed for ${proof.sha}: https://github.com/${repository}/actions/runs/${proof.runId}/attempts/${proof.attempt} (job ${proof.jobId}).`;
|
||||
console.log(message);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, `${message}\n`);
|
||||
|
||||
@@ -1,8 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createActionsReader, readSourceVerification, sourceVerificationJob, waitForSourceVerification } from "./cloud-source-verification.mjs";
|
||||
import test, { describe } from "node:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createActionsReader, readSourceVerification, sourceVerificationJob, waitForSourceVerification, SourceVerificationDeferred } from "./cloud-source-verification.mjs";
|
||||
|
||||
import { releaseAdmitted, deferredProducer, requestDeferredProducer, sourceAdmissionJob, migratorAdmissionJob, createReleaseRerunner } from "./cloud-release-admission.mjs";
|
||||
|
||||
import { cancelImageRun, runnerWaits, watchReleaseSource } from "./watch-release-source.mjs";
|
||||
|
||||
const sha = "a".repeat(40);
|
||||
|
||||
function runAdmissionCli({ active = false, imageOnly = false, repositoryName = "paperclipai/paperclip" } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "release-admission-cli-"));
|
||||
try {
|
||||
const preload = join(root, "github-fixture.mjs");
|
||||
const installFixture = (active) => {
|
||||
const repository = { id: 1170821064, full_name: "paperclipai/paperclip" };
|
||||
const run = { id: 77, workflow_id: 21, path: ".github/workflows/docker.yml", head_sha: "a".repeat(40),
|
||||
repository, head_repository: repository, head_branch: "master", event: "push", run_attempt: 1, status: "in_progress" };
|
||||
globalThis.fetch = async url => {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.origin !== "https://api.github.com") throw new Error("Unexpected admission request origin");
|
||||
const path = parsed.pathname;
|
||||
if (path.endsWith("/docker.yml")) return Response.json({ id: 21, path: ".github/workflows/docker.yml" });
|
||||
if (path.endsWith("/release.yml")) return Response.json({ id: 22, path: ".github/workflows/release.yml" });
|
||||
if (path.endsWith("/workflows/21/runs")) return Response.json({ total_count: active ? 1 : 0, workflow_runs: active ? [run] : [] });
|
||||
if (path.endsWith("/workflows/22/runs")) return Response.json({ total_count: 0, workflow_runs: [] });
|
||||
if (path.endsWith("/runs/77")) return Response.json(run);
|
||||
throw new Error("Unexpected admission request path");
|
||||
};
|
||||
};
|
||||
writeFileSync(preload, "(" + installFixture.toString() + ")(" + JSON.stringify(active) + ");\n");
|
||||
const output = join(root, "output");
|
||||
const result = spawnSync(process.execPath, ["--import", preload, fileURLToPath(new URL("./cloud-release-admission.mjs", import.meta.url)), ...(imageOnly ? ["image-only"] : [])], {
|
||||
encoding: "utf8", timeout: 5_000,
|
||||
env: { ...process.env, GITHUB_REPOSITORY: repositoryName, GITHUB_REF: "refs/heads/master", GITHUB_SHA: "a".repeat(40),
|
||||
GITHUB_EVENT_NAME: "push", GITHUB_RUN_ATTEMPT: "1", GITHUB_TOKEN: "fixture", GITHUB_OUTPUT: output },
|
||||
});
|
||||
return { ...result, output: result.status === 0 ? readFileSync(output, "utf8") : "" };
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("admission CLI startup", () => {
|
||||
for (const imageOnly of [false, true]) {
|
||||
for (const active of [false, true]) {
|
||||
test("executes " + (imageOnly ? "image-only" : "source") + " admission with active consumer=" + active, () => {
|
||||
const result = runAdmissionCli({ active, imageOnly });
|
||||
assert.equal(result.status, 0, result.stderr || result.error?.message);
|
||||
assert.equal(result.output, "admitted=" + active + "\n");
|
||||
});
|
||||
}
|
||||
}
|
||||
test("rejects a noncanonical CLI context before discovering consumers", () => {
|
||||
const result = runAdmissionCli({ repositoryName: "fork/paperclip" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Canonical master admission context required/);
|
||||
assert.equal(result.output, "");
|
||||
});
|
||||
});
|
||||
const workflow = { id: 123, path: ".github/workflows/cloud-readiness.yml" };
|
||||
const baseRun = {
|
||||
id: 456, workflow_id: workflow.id, path: workflow.path, run_attempt: 2,
|
||||
@@ -62,7 +122,8 @@ test("job identities and terminal failures fail closed", async () => {
|
||||
{ head_sha: "b".repeat(40) }, { run_id: 999 }, { run_attempt: 1 },
|
||||
{ conclusion: "failure" }, { conclusion: "cancelled" }, { conclusion: "skipped" },
|
||||
]) {
|
||||
const { api } = fixture({ jobs: [{ ...baseJob, ...overrides }] });
|
||||
const { api } = fixture({ jobs: [{ ...baseJob, ...overrides }],
|
||||
current: overrides.conclusion === "skipped" ? { ...baseRun, status: "completed", conclusion: "success" } : baseRun });
|
||||
await assert.rejects(readSourceVerification(sha, api), /did not pass/);
|
||||
}
|
||||
});
|
||||
@@ -231,3 +292,357 @@ test("retries stop at the caller's deadline rather than outliving the poll", asy
|
||||
assert.equal(seen.length, 1);
|
||||
assert.deepEqual(waits, []);
|
||||
});
|
||||
|
||||
|
||||
describe("image build source monitor", () => {
|
||||
const sha = "a".repeat(40);
|
||||
const runId = 50;
|
||||
const attempt = 1;
|
||||
const workflow = { id: 123, path: ".github/workflows/cloud-readiness.yml" };
|
||||
const sourceRun = { id: 99, workflow_id: 123, path: workflow.path, run_attempt: 1,
|
||||
repository: { full_name: "paperclipai/paperclip" }, head_repository: { full_name: "paperclipai/paperclip" },
|
||||
head_sha: sha, head_branch: "master", event: "push", status: "in_progress" };
|
||||
const imageRun = { id: runId, run_attempt: attempt, head_sha: sha, head_branch: "master", event: "push",
|
||||
repository: { id: 1170821064 }, head_repository: { id: 1170821064 },
|
||||
path: ".github/workflows/docker.yml", status: "in_progress" };
|
||||
const proof = { id: 111, name: "Cloud source verified v1", run_id: 99, run_attempt: 1,
|
||||
head_sha: sha, status: "completed", conclusion: "success" };
|
||||
|
||||
function fixture({ jobs = [proof], run = sourceRun, current = run, image = imageRun, imageJobs = [], missing = false } = {}) {
|
||||
const cancellations = [];
|
||||
const messages = [];
|
||||
const api = async path => {
|
||||
if (path.endsWith("/workflows/cloud-readiness.yml")) return workflow;
|
||||
if (path.includes("/workflows/123/runs?")) return { workflow_runs: missing ? [] : [run], total_count: missing ? 0 : 1 };
|
||||
if (path.includes("/runs/99/attempts/1/jobs?")) return { jobs };
|
||||
if (path.endsWith("/runs/99")) return current;
|
||||
if (path.includes(`/runs/${runId}/attempts/${attempt}/jobs?`)) return { jobs: imageJobs, total_count: imageJobs.length };
|
||||
if (path.endsWith(`/runs/${runId}`)) return image;
|
||||
throw new Error(`Unexpected request: ${path}`);
|
||||
};
|
||||
return { sha, runId, attempt, api, cancel: async path => cancellations.push(path),
|
||||
cancellations, messages, log: message => messages.push(message), summary: async message => messages.push(message) };
|
||||
}
|
||||
|
||||
test("successful exact-source proof permits publication without canceling image work", async () => {
|
||||
const f = fixture();
|
||||
assert.equal((await watchReleaseSource(f)).jobId, proof.id);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
});
|
||||
|
||||
test("a required check failing cancels the same image attempt before siblings finish", async () => {
|
||||
const f = fixture({ jobs: [{ ...proof, name: "General tests", conclusion: "failure" }] });
|
||||
await assert.rejects(watchReleaseSource(f), /General tests/);
|
||||
assert.deepEqual(f.cancellations, ["/repos/paperclipai/paperclip/actions/runs/99/cancel", `/repos/paperclipai/paperclip/actions/runs/${runId}/cancel`]);
|
||||
});
|
||||
|
||||
test("a superseded readiness attempt cancels image work with no deployable future", async () => {
|
||||
const f = fixture({ jobs: [], run: { ...sourceRun, status: "completed", conclusion: "cancelled" } });
|
||||
await assert.rejects(watchReleaseSource(f), /did not pass/);
|
||||
assert.equal(f.cancellations.length, 1);
|
||||
});
|
||||
|
||||
test("a rerun racing a failed job is retried rather than canceling the image", async () => {
|
||||
const f = fixture({ jobs: [{ ...proof, name: "General tests", conclusion: "failure" }],
|
||||
current: { ...sourceRun, run_attempt: 2 } });
|
||||
let time = 0;
|
||||
const api = f.api;
|
||||
f.api = async path => time > 0 ? fixture().api(path) : api(path);
|
||||
const result = await watchReleaseSource({ ...f, now: () => time, sleep: async ms => { time += ms; } });
|
||||
assert.equal(result.jobId, proof.id);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
});
|
||||
|
||||
test("a transport failure does not issue a destructive cancellation", async () => {
|
||||
const f = fixture();
|
||||
await assert.rejects(watchReleaseSource({ ...f, api: async () => { throw new Error("GitHub unavailable"); } }), /unavailable/);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
});
|
||||
|
||||
test("the observation window ends without cancelling healthy pending verification", async () => {
|
||||
const f = fixture({ missing: true });
|
||||
let time = 0;
|
||||
const proof = await watchReleaseSource({ ...f, now: () => time, sleep: async ms => { time += ms; }, timeoutMs: 20 });
|
||||
assert.equal(proof, undefined);
|
||||
assert.equal(time, 20);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
assert.ok(f.messages.some(message => message.includes("final publication still requires exact-source verification")));
|
||||
});
|
||||
|
||||
test("final publication requires proof even after the early observation window ends", async () => {
|
||||
const f = fixture({ missing: true });
|
||||
let time = 0;
|
||||
await assert.rejects(watchReleaseSource({ ...f, requireProof: true, now: () => time,
|
||||
sleep: async ms => { time += ms; }, timeoutMs: 20 }), /before the publication deadline/);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
assert.equal((await watchReleaseSource({ ...fixture(), requireProof: true })).jobId, proof.id);
|
||||
});
|
||||
|
||||
test("cancellation rejects another SHA, repository, ref, workflow, or attempt", async () => {
|
||||
for (const change of [{ head_sha: "b".repeat(40) }, { head_branch: "v1" }, { event: "pull_request" },
|
||||
{ path: ".github/workflows/release.yml" }, { run_attempt: 2 }, { id: 51 },
|
||||
{ repository: { id: 42 } }, { head_repository: { id: 42 } }]) {
|
||||
const f = fixture({ image: { ...imageRun, ...change } });
|
||||
await assert.rejects(cancelImageRun(f), /different image workflow identity/);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("queue measurement does not count running time or skipped dependency jobs", () => {
|
||||
const created_at = "2020-01-01T00:00:00Z";
|
||||
const started_at = "2020-01-01T00:00:20Z";
|
||||
const job = { id: 1, name: "amd64", labels: ["release"], created_at, started_at, runner_id: 10, status: "in_progress" };
|
||||
const now = Date.parse(created_at) + 120_000;
|
||||
assert.deepEqual(runnerWaits([job, { ...job, status: "completed", conclusion: "skipped" }], now), [{ id: 1, job: "amd64", seconds: 20 }]);
|
||||
assert.equal(runnerWaits([{ ...job, status: "queued", runner_id: 0 }], now)[0].seconds, 120);
|
||||
});
|
||||
|
||||
test("a runner wait over 60 seconds produces an Actions warning and summary", async () => {
|
||||
const f = fixture({ imageJobs: [{ id: 1, name: "amd64", labels: ["release"], status: "queued", runner_id: 0, created_at: "2020-01-01T00:00:00Z" }] });
|
||||
await watchReleaseSource({ ...f, now: () => Date.parse("2020-01-01T00:02:00Z") });
|
||||
assert.ok(f.messages.some(message => message.startsWith("::warning") && message.includes("120s")));
|
||||
assert.ok(f.messages.some(message => message.includes("Release runner wait: amd64")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("release candidate admission", () => {
|
||||
const repository = { id: 1170821064, full_name: "paperclipai/paperclip" };
|
||||
const ids = { "docker.yml": 101, "release.yml": 102, "cloud-readiness.yml": 123, "cloud-migrator-artifacts.yml": 124 };
|
||||
function run(file, change = {}) {
|
||||
return { id: ids[file] + 1000, workflow_id: ids[file], path: `.github/workflows/${file}`,
|
||||
repository, head_repository: repository, head_sha: sha, head_branch: "master", event: "push",
|
||||
run_attempt: 1, status: "in_progress", conclusion: null, ...change };
|
||||
}
|
||||
function job(run, name, conclusion = "skipped") {
|
||||
return { id: 789, run_id: run.id, run_attempt: run.run_attempt, head_sha: run.head_sha,
|
||||
name, status: "completed", conclusion };
|
||||
}
|
||||
function fixture(file = "cloud-readiness.yml", change = {}) {
|
||||
const producer = run(file, { status: "completed", conclusion: "success", ...change });
|
||||
const admission = file === "cloud-migrator-artifacts.yml" ? migratorAdmissionJob : sourceAdmissionJob;
|
||||
const f = { runs: { [file]: [producer] }, jobs: { [producer.id]: [job(producer, admission, "success"), job(producer, sourceVerificationJob)] },
|
||||
current: {}, writes: [], calls: [], requested: new Set(), file, sha, admissionJob: admission };
|
||||
f.api = async path => {
|
||||
f.calls.push(path);
|
||||
const url = new URL(`https://api.github.com${path}`);
|
||||
const fileMatch = path.match(/\/workflows\/([^/?]+\.yml)$/);
|
||||
if (fileMatch) return { id: ids[fileMatch[1]], path: `.github/workflows/${fileMatch[1]}` };
|
||||
const listMatch = path.match(/\/workflows\/(\d+)\/runs\?/);
|
||||
if (listMatch) {
|
||||
const name = Object.keys(ids).find(file => ids[file] === Number(listMatch[1]));
|
||||
const runs = f.runs[name] ?? [];
|
||||
return { workflow_runs: runs, total_count: f.total ?? runs.length };
|
||||
}
|
||||
const jobMatch = path.match(/\/runs\/(\d+)\/attempts\/(\d+)\/jobs\?/);
|
||||
if (jobMatch) {
|
||||
const page = Number(url.searchParams.get("page") ?? "1");
|
||||
const jobs = f.jobs[Number(jobMatch[1])] ?? [];
|
||||
return { total_count: f.jobTotal ?? jobs.length, jobs: jobs.slice((page - 1) * 100, page * 100) };
|
||||
}
|
||||
const currentMatch = path.match(/\/runs\/(\d+)$/);
|
||||
if (currentMatch) return f.current[Number(currentMatch[1])] ?? Object.values(f.runs).flat().find(run => run.id === Number(currentMatch[1]));
|
||||
throw new Error(`Unexpected admission API path: ${path}`);
|
||||
};
|
||||
f.rerun = async path => f.writes.push(path);
|
||||
return f;
|
||||
}
|
||||
|
||||
test("only a running image or npm release admits a normal push", async () => {
|
||||
for (const file of ["docker.yml", "release.yml"]) {
|
||||
const f = fixture();
|
||||
assert.equal(await releaseAdmitted(sha, f), false);
|
||||
f.runs[file] = [run(file)];
|
||||
assert.equal(await releaseAdmitted(sha, f), true);
|
||||
assert.equal(await releaseAdmitted(sha, { ...f, imageOnly: true }), file === "docker.yml");
|
||||
for (const status of ["queued", "pending", "completed"]) {
|
||||
f.runs[file] = [run(file, { status })];
|
||||
assert.equal(await releaseAdmitted(sha, f), false, status);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("manual and retry requests admit exact source without consumer discovery", async () => {
|
||||
const api = () => assert.fail("explicit request needs no discovery");
|
||||
assert.equal(await releaseAdmitted(sha, { api, event: "workflow_dispatch" }), true);
|
||||
assert.equal(await releaseAdmitted(sha, { api, attempt: 2 }), true);
|
||||
assert.equal(await releaseAdmitted(sha, { api, event: "pull_request", attempt: 2 }), false);
|
||||
for (const attempt of [0, "2"]) {
|
||||
await assert.rejects(releaseAdmitted(sha, { api, attempt }), /Invalid release attempt/);
|
||||
}
|
||||
});
|
||||
|
||||
test("forks, stale identities, tags, and non-push consumers do not admit work", async () => {
|
||||
for (const change of [{ repository: { ...repository, id: 2 } }, { head_repository: { ...repository, id: 2 } },
|
||||
{ repository: { ...repository, full_name: "fork/paperclip" } }, { path: ".github/workflows/pr.yml" },
|
||||
{ workflow_id: 999 }, { head_sha: "b".repeat(40) }, { head_branch: "v1" }, { event: "workflow_dispatch" },
|
||||
{ id: 0 }, { run_attempt: 0 }]) {
|
||||
const f = fixture();
|
||||
f.runs["docker.yml"] = [run("docker.yml", change)];
|
||||
assert.equal(await releaseAdmitted(sha, f), false, JSON.stringify(change));
|
||||
}
|
||||
const f = fixture();
|
||||
const active = run("docker.yml");
|
||||
f.runs["docker.yml"] = [active];
|
||||
f.current[active.id] = { ...active, run_attempt: 2 };
|
||||
assert.equal(await releaseAdmitted(sha, f), false);
|
||||
});
|
||||
|
||||
test("incomplete discovery and transport failure fail closed", async () => {
|
||||
for (const total of [1, 101, -1]) {
|
||||
const f = fixture();
|
||||
f.total = total;
|
||||
await assert.rejects(releaseAdmitted(sha, f), /Incomplete/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
}
|
||||
await assert.rejects(releaseAdmitted(sha, { api: async () => { throw new Error("API unavailable"); } }), /unavailable/);
|
||||
await assert.rejects(releaseAdmitted("master", { api: () => assert.fail() }), /full source SHA/);
|
||||
});
|
||||
|
||||
test("a selected consumer restarts only a strictly deferred producer, once", async () => {
|
||||
for (const file of ["cloud-readiness.yml", "cloud-migrator-artifacts.yml"]) {
|
||||
const f = fixture(file);
|
||||
assert.equal(await requestDeferredProducer(f), "pending");
|
||||
assert.deepEqual(f.writes, [`/repos/paperclipai/paperclip/actions/runs/${ids[file] + 1000}/rerun`]);
|
||||
await requestDeferredProducer(f);
|
||||
assert.equal(f.writes.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed, incomplete, ambiguous, or mismatched work is never automatically retried", async () => {
|
||||
for (const change of [jobs => { jobs[1].conclusion = "failure"; }, jobs => { jobs[0].conclusion = "failure"; },
|
||||
jobs => { jobs[1].run_attempt = 2; }, jobs => { jobs[1].head_sha = "b".repeat(40); },
|
||||
jobs => { jobs[1].run_id = 2; }, jobs => { jobs[1].status = "queued"; },
|
||||
jobs => { jobs.push({ ...jobs[0] }); }, jobs => { jobs.splice(1); }]) {
|
||||
const f = fixture();
|
||||
change(f.jobs[1123]);
|
||||
assert.equal(await requestDeferredProducer(f), "finished");
|
||||
assert.deepEqual(f.writes, []);
|
||||
}
|
||||
const f = fixture();
|
||||
for (const change of [{ run_attempt: 2 }, { status: "in_progress" }, { head_sha: "b".repeat(40) }, { id: 2 }]) {
|
||||
f.current[1123] = { ...f.runs[f.file][0], ...change };
|
||||
assert.equal(await requestDeferredProducer(f), "pending");
|
||||
assert.deepEqual(f.writes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("an explicitly cancelled or failed producer cannot be restarted by admission", async () => {
|
||||
for (const conclusion of ["cancelled", "failure", "timed_out"]) {
|
||||
const f = fixture("cloud-readiness.yml", { conclusion });
|
||||
assert.equal(await requestDeferredProducer(f), "finished");
|
||||
assert.deepEqual(f.writes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("a truncated job page cannot hide a failed check and trigger a retry", async () => {
|
||||
const f = fixture();
|
||||
f.jobTotal = 3;
|
||||
await assert.rejects(requestDeferredProducer(f), /Incomplete release producer job list/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
f.jobTotal = "2";
|
||||
await assert.rejects(requestDeferredProducer(f), /Invalid release producer job list/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
test("producer retries reject arbitrary workflow and admission contracts", async () => {
|
||||
const f = fixture();
|
||||
for (const change of [{ file: "docker.yml" }, { admissionJob: migratorAdmissionJob }]) {
|
||||
await assert.rejects(requestDeferredProducer({ ...f, ...change }), /Unknown deferred/);
|
||||
}
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("a deferred completion racing the run listing does not become a source failure", async () => {
|
||||
const f = fixture();
|
||||
const completed = f.runs[f.file][0];
|
||||
f.runs[f.file] = [{ ...completed, status: "in_progress", conclusion: null }];
|
||||
f.current[completed.id] = completed;
|
||||
await assert.rejects(readSourceVerification(sha, f.api), SourceVerificationDeferred);
|
||||
delete f.current[completed.id];
|
||||
assert.equal(await readSourceVerification(sha, f.api), undefined);
|
||||
});
|
||||
|
||||
test("a competing consumer's accepted retry does not fail the losing consumer", async () => {
|
||||
const f = fixture();
|
||||
f.rerun = async () => {
|
||||
f.current[1123] = run(f.file, { run_attempt: 2 });
|
||||
throw new Error("duplicate restart rejected");
|
||||
};
|
||||
assert.equal(await requestDeferredProducer(f), "pending");
|
||||
assert.equal(f.requested.size, 1);
|
||||
const rejected = fixture();
|
||||
rejected.rerun = async () => { throw new Error("token denied"); };
|
||||
await assert.rejects(requestDeferredProducer(rejected), /token denied/);
|
||||
assert.equal(rejected.requested.size, 0);
|
||||
});
|
||||
|
||||
test("a queued migrator is pending until its build actually starts", async () => {
|
||||
const f = fixture("cloud-migrator-artifacts.yml", { status: "in_progress", conclusion: null });
|
||||
const producer = f.runs[f.file][0];
|
||||
const build = { ...job(producer, "Build migrator", null), status: "queued", labels: ["cloud-release-32"] };
|
||||
f.jobs[producer.id] = [job(producer, migratorAdmissionJob, "success"), build];
|
||||
assert.equal(await requestDeferredProducer(f), "pending");
|
||||
build.status = "in_progress";
|
||||
assert.equal(await requestDeferredProducer(f), "admitted");
|
||||
build.run_attempt = 2;
|
||||
assert.equal(await requestDeferredProducer(f), "pending", "a stale build cannot admit work");
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
test("a rejected duplicate with stale read visibility remains pending without claiming admission", async () => {
|
||||
for (const status of [409, 422, 403]) {
|
||||
const f = fixture();
|
||||
f.rerun = async () => { throw Object.assign(new Error("retry rejected"), { status }); };
|
||||
if (status === 403) await assert.rejects(requestDeferredProducer(f), /retry rejected/);
|
||||
else assert.equal(await requestDeferredProducer(f), "pending");
|
||||
assert.equal(f.requested.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("source proof waits for admission, restarts it, and accepts only the new attempt", async () => {
|
||||
const f = fixture();
|
||||
await assert.rejects(readSourceVerification(sha, f.api), SourceVerificationDeferred);
|
||||
let time = 0;
|
||||
const proof = await waitForSourceVerification(sha, { ...f, now: () => time, intervalMs: 10, timeoutMs: 30, log: () => {},
|
||||
sleep: async ms => {
|
||||
time += ms;
|
||||
const selected = run(f.file, { run_attempt: 2 });
|
||||
f.runs[f.file] = [selected];
|
||||
f.jobs[selected.id] = [job(selected, sourceAdmissionJob, "success"), job(selected, sourceVerificationJob, "success")];
|
||||
} });
|
||||
assert.equal(proof.attempt, 2);
|
||||
assert.equal(time, 10);
|
||||
assert.equal(f.writes.length, 1);
|
||||
});
|
||||
|
||||
test("the image monitor admits a late migrator even when source proof is ready", async () => {
|
||||
const f = fixture("cloud-migrator-artifacts.yml");
|
||||
const source = run("cloud-readiness.yml");
|
||||
f.runs["cloud-readiness.yml"] = [source];
|
||||
f.jobs[source.id] = [job(source, sourceVerificationJob, "success")];
|
||||
const image = run("docker.yml");
|
||||
f.runs["docker.yml"] = [image];
|
||||
f.jobs[image.id] = [];
|
||||
let time = 0;
|
||||
const proof = await watchReleaseSource({ ...f, runId: image.id, attempt: 1, cancel: () => assert.fail("must not cancel"),
|
||||
now: () => time, intervalMs: 10, timeoutMs: 30, log: () => {}, summary: async () => {}, sleep: async ms => {
|
||||
time += ms;
|
||||
const selected = run(f.file, { run_attempt: 2 });
|
||||
f.runs[f.file] = [selected];
|
||||
f.jobs[selected.id] = [{ ...job(selected, "Build migrator", null), status: "in_progress", labels: ["cloud-release-32"] }];
|
||||
} });
|
||||
assert.equal(proof.sha, sha);
|
||||
assert.equal(time, 10);
|
||||
assert.equal(f.writes.length, 1);
|
||||
});
|
||||
|
||||
test("the retry transport is a bounded POST to a canonical run", async () => {
|
||||
const calls = [];
|
||||
const rerun = createReleaseRerunner("fixture", async (url, options) => { calls.push({ url, options }); return { ok: true }; });
|
||||
await assert.rejects(rerun("/repos/fork/paperclip/actions/runs/1/rerun"), /Invalid release retry/);
|
||||
await rerun("/repos/paperclipai/paperclip/actions/runs/1/rerun");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].options.method, "POST");
|
||||
assert.equal(calls[0].options.redirect, "error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// Statuses that say "ask again", not "the answer is no". A release must not be
|
||||
// blocked because GitHub returned a gateway error during a 60-minute poll.
|
||||
const TRANSIENT_READ_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
|
||||
// A rate-limited read also says "ask again", and GitHub reports both primary
|
||||
// and secondary rate limits as 403. Only the headers separate that from a
|
||||
// token that may not read Actions, which must still fail at once.
|
||||
function rateLimited(response) {
|
||||
if (response.status !== 403) return false;
|
||||
const header = (name) => response.headers?.get?.(name) ?? null;
|
||||
return header("retry-after") !== null || header("x-ratelimit-remaining") === "0";
|
||||
}
|
||||
|
||||
/** Milliseconds from a Retry-After header, when it carries a sane delay. */
|
||||
function retryAfterMs(response, capMs) {
|
||||
const value = Number(response?.headers?.get?.("retry-after"));
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.min(value * 1_000, capMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* The GitHub Actions read used by the polling below, with transient transport
|
||||
* failures retried: network errors, the statuses above, rate-limited 403s, and
|
||||
* a body that fails while it is being read. Any other non-OK status throws on
|
||||
* the first response, because waiting out a wrong token only delays the news.
|
||||
*
|
||||
* `deadlineAt` bounds retries by the caller's own polling deadline, so a read
|
||||
* cannot extend the wait past the timeout it belongs to.
|
||||
*/
|
||||
export function createActionsReader({
|
||||
token, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
attempts = 4, backoffMs = 1_000, log = console.log,
|
||||
now = Date.now, deadlineAt = () => Infinity, maxRetryAfterMs = 60_000,
|
||||
} = {}) {
|
||||
return async (path) => {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
// Retry only while both the attempt budget and the caller's deadline
|
||||
// leave room for the wait this attempt would cost.
|
||||
const waitMs = backoffMs * attempt;
|
||||
const retryable = attempt < attempts && now() + waitMs < deadlineAt();
|
||||
const pause = (response) => sleep(retryAfterMs(response, maxRetryAfterMs) ?? waitMs);
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(`https://api.github.com${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
||||
signal: AbortSignal.timeout(30_000), redirect: "error",
|
||||
});
|
||||
} catch (cause) {
|
||||
if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`);
|
||||
log(`GitHub Actions read failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`);
|
||||
await pause();
|
||||
continue;
|
||||
}
|
||||
if (response.ok) {
|
||||
try {
|
||||
// A 200 whose body is truncated or undecodable is a transport
|
||||
// failure like any other, so it belongs inside the retry.
|
||||
return await response.json();
|
||||
} catch (cause) {
|
||||
if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`);
|
||||
log(`GitHub Actions read body failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`);
|
||||
await pause();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!retryable || !(TRANSIENT_READ_STATUSES.has(response.status) || rateLimited(response))) {
|
||||
throw new Error(`GitHub Actions read failed (HTTP ${response.status}).`);
|
||||
}
|
||||
log(`GitHub Actions read failed (HTTP ${response.status}); retrying (${attempt}/${attempts - 1}).`);
|
||||
await pause(response);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface SleepQualificationIdentity {
|
||||
appSha: string;
|
||||
cloudSha: string;
|
||||
target: string;
|
||||
configDigest: string;
|
||||
collectorOrigins?: { telemetry: string; otel: string };
|
||||
}
|
||||
export interface SleepQualificationReport {
|
||||
version: number;
|
||||
target: string;
|
||||
appSha: string;
|
||||
cloudSha: string;
|
||||
configDigest: string;
|
||||
layers: Record<string, { verdict: 'VERIFIED' | 'NOT VERIFIED' | 'INCONCLUSIVE'; reason: string }>;
|
||||
}
|
||||
export function logicalSleepConfiguration(configuration: unknown): {
|
||||
automaticBackups: { intervalMinutes: number; idleCheckpoint: boolean };
|
||||
announcements: boolean;
|
||||
telemetry: boolean;
|
||||
otel: { enabled: boolean; protocol: 'http/json' };
|
||||
serveUi: boolean;
|
||||
managedInstance: boolean;
|
||||
deploymentMode: 'authenticated' | 'local_trusted';
|
||||
storageProvider: 's3' | 'local_disk';
|
||||
};
|
||||
export function sleepConfigurationDigest(configuration: unknown): string;
|
||||
export interface SleepSignalObservation {
|
||||
telemetryRequests: number; traceRequests: number; exportedSpans: number; qualificationSpans: number;
|
||||
metricRequests: number; logRequests: number; malformedTraceRequests: number; unexpectedRequests: number;
|
||||
}
|
||||
export interface IncomingHttpSnapshot {
|
||||
source: 'node-http-server-events';
|
||||
captureId: string;
|
||||
installed: boolean;
|
||||
valid: boolean;
|
||||
requests: number;
|
||||
observedAt: string;
|
||||
}
|
||||
export interface IncomingHttpWindow {
|
||||
before: IncomingHttpSnapshot;
|
||||
after: IncomingHttpSnapshot;
|
||||
requests: number;
|
||||
}
|
||||
export function observeIncomingHttpWindow(before: IncomingHttpSnapshot, after: IncomingHttpSnapshot): IncomingHttpWindow;
|
||||
export function observedNoIncomingHttp(observation: unknown, quiet: { startedAt: string; finishedAt: string }): boolean;
|
||||
export function observeConfiguredSignals(requests: ReadonlyArray<{ path: string; body: unknown }>): SleepSignalObservation;
|
||||
export function configuredSleepFeatures(observations: Record<string, unknown>): {
|
||||
telemetry: boolean; announcements: boolean; automaticBackups: boolean; otel: boolean; exportedSpans: number;
|
||||
};
|
||||
export function evaluateSleepQualification(receipt: unknown, expected: SleepQualificationIdentity): SleepQualificationReport;
|
||||
export function finalizeLocalSleepReceipt<T extends Record<string, unknown>>(observations: T, expected: SleepQualificationIdentity, now?: () => string): {
|
||||
receipt: T & { finishedAt: string };
|
||||
report: SleepQualificationReport;
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const sha = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
|
||||
const finite = value => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
const stamp = value => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
const layers = ['workerQuiet', 'eligibility', 'databaseSuspension', 'hostSuspension', 'backupPersistence', 'inboundWake', 'timedWake', 'returnToIdle'];
|
||||
const result = (verdict, reason) => ({ verdict, reason });
|
||||
const announcementFeed = 'https://pages.paperclip.ing/announcements/v1/current.json';
|
||||
const observedAnnouncement = announcement => announcement?.intercepted === true
|
||||
&& Array.isArray(announcement.requests) && announcement.requests.length > 0
|
||||
&& announcement.requests.every(url => url === announcementFeed)
|
||||
&& Array.isArray(announcement.unmatched) && announcement.unmatched.length === 0;
|
||||
const signalKeys = ['telemetryRequests', 'traceRequests', 'exportedSpans', 'qualificationSpans', 'metricRequests', 'logRequests', 'malformedTraceRequests', 'unexpectedRequests'];
|
||||
const knownSignalCounts = signal => signal && signalKeys.every(key => Number.isSafeInteger(signal[key]) && signal[key] >= 0);
|
||||
|
||||
/** Preserve actual cumulative app-server snapshots; IPC reads create no HTTP traffic. */
|
||||
export function observeIncomingHttpWindow(before, after) {
|
||||
return { before, after, requests: after?.requests - before?.requests };
|
||||
}
|
||||
const knownIncomingHttpSnapshot = snapshot => snapshot?.source === 'node-http-server-events'
|
||||
&& typeof snapshot.captureId === 'string' && /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/.test(snapshot.captureId)
|
||||
&& snapshot.installed === true && snapshot.valid === true
|
||||
&& Number.isSafeInteger(snapshot.requests) && snapshot.requests >= 0 && stamp(snapshot.observedAt);
|
||||
export const observedNoIncomingHttp = (observation, quiet) => knownIncomingHttpSnapshot(observation?.before)
|
||||
&& knownIncomingHttpSnapshot(observation?.after) && observation.before.captureId === observation.after.captureId
|
||||
&& stamp(quiet?.startedAt) && stamp(quiet?.finishedAt) && Date.parse(quiet.finishedAt) >= Date.parse(quiet.startedAt)
|
||||
&& Date.parse(observation.before.observedAt) <= Date.parse(quiet.startedAt)
|
||||
&& Date.parse(observation.after.observedAt) >= Date.parse(quiet.finishedAt)
|
||||
&& Number.isSafeInteger(observation.requests) && observation.requests === 0
|
||||
&& observation.after.requests - observation.before.requests === observation.requests;
|
||||
|
||||
/** Count actual collector requests and OTLP span records, never request-as-span estimates. */
|
||||
export function observeConfiguredSignals(requests) {
|
||||
const observed = Object.fromEntries(signalKeys.map(key => [key, 0]));
|
||||
for (const request of requests) {
|
||||
if (typeof request?.path !== 'string' || !request.path.startsWith('/') || request.path.startsWith('//')) { observed.unexpectedRequests++; continue; }
|
||||
const pathname = new URL(request.path, 'http://owned-collector').pathname;
|
||||
if (pathname === '/telemetry') { observed.telemetryRequests++; continue; }
|
||||
if (pathname === '/v1/metrics') { observed.metricRequests++; continue; }
|
||||
if (pathname === '/v1/logs') { observed.logRequests++; continue; }
|
||||
if (pathname !== '/v1/traces') { observed.unexpectedRequests++; continue; }
|
||||
observed.traceRequests++;
|
||||
try {
|
||||
if (!Array.isArray(request.body?.resourceSpans)) throw new Error('Missing resourceSpans');
|
||||
let spans = 0, qualificationSpans = 0;
|
||||
for (const resource of request.body.resourceSpans) {
|
||||
if (!Array.isArray(resource?.scopeSpans)) throw new Error('Missing scopeSpans');
|
||||
for (const scope of resource.scopeSpans) {
|
||||
if (!Array.isArray(scope?.spans)) throw new Error('Missing spans');
|
||||
for (const span of scope.spans) {
|
||||
if (typeof span?.name !== 'string' || !span.name) throw new Error('Invalid span');
|
||||
spans++; if (span.name === 'sleep-qualification-stimulus') qualificationSpans++;
|
||||
}
|
||||
}
|
||||
}
|
||||
observed.exportedSpans += spans; observed.qualificationSpans += qualificationSpans;
|
||||
} catch { observed.malformedTraceRequests++; }
|
||||
}
|
||||
return observed;
|
||||
}
|
||||
|
||||
/** Coverage booleans come from recorded activity; the evaluator also checks their provenance. */
|
||||
export function configuredSleepFeatures({ announcement, signalObservation, backupCheckpoint }) {
|
||||
const known = knownSignalCounts(signalObservation);
|
||||
return { telemetry: Boolean(known && signalObservation.telemetryRequests > 0), announcements: Boolean(observedAnnouncement(announcement)),
|
||||
automaticBackups: backupCheckpoint?.owner === 'normal-idle-checkpoint' && backupCheckpoint.wakeMarkerObserved === true
|
||||
&& backupCheckpoint.failureObserved === false && Array.isArray(backupCheckpoint.archives) && backupCheckpoint.archives.length > 0,
|
||||
otel: Boolean(known && signalObservation.traceRequests > 0 && signalObservation.exportedSpans > 0
|
||||
&& signalObservation.qualificationSpans > 0 && signalObservation.qualificationSpans <= signalObservation.exportedSpans
|
||||
&& signalObservation.metricRequests === 0 && signalObservation.logRequests === 0
|
||||
&& signalObservation.malformedTraceRequests === 0 && signalObservation.unexpectedRequests === 0),
|
||||
exportedSpans: known ? signalObservation.exportedSpans : 0 };
|
||||
}
|
||||
const validBackupCheckpoint = (backup, inRun, quietFinishedAt) => backup?.owner === 'normal-idle-checkpoint' && backup.wakeMarkerObserved === true
|
||||
&& backup.failureObserved === false && inRun(backup.startedAt) && inRun(backup.observedAt)
|
||||
&& Date.parse(backup.startedAt) >= Date.parse(quietFinishedAt) && Date.parse(backup.observedAt) >= Date.parse(backup.startedAt) && Array.isArray(backup.archives) && backup.archives.length > 0
|
||||
&& backup.archives.every(archive => typeof archive.name === 'string' && /^[^/\\]+\.sql\.gz$/.test(archive.name)
|
||||
&& finite(archive.bytes) && archive.bytes > 0 && finite(archive.sqlBytes) && archive.sqlBytes > 0
|
||||
&& /^[a-f0-9]{64}$/.test(archive.sha256 ?? '') && archive.gzipVerified === true && archive.fixtureIdentifierFound === true
|
||||
&& ['birthtime', 'mtime'].includes(archive.createdAtSource)
|
||||
&& inRun(archive.createdAt) && Date.parse(archive.createdAt) >= Date.parse(backup.startedAt) && Date.parse(archive.createdAt) <= Date.parse(backup.observedAt));
|
||||
|
||||
/** Canonical logical configuration: ephemeral collector addresses are bound separately. */
|
||||
export function logicalSleepConfiguration(configuration) {
|
||||
if (!configuration || typeof configuration.announcements !== 'boolean' || typeof configuration.serveUi !== 'boolean'
|
||||
|| typeof configuration.telemetryOrigin !== 'string' || typeof configuration.otelOrigin !== 'string'
|
||||
|| (configuration.managedInstance !== undefined && typeof configuration.managedInstance !== 'boolean')
|
||||
|| (configuration.deploymentMode !== undefined && !['authenticated', 'local_trusted'].includes(configuration.deploymentMode))
|
||||
|| (configuration.storageProvider !== undefined && !['s3', 'local_disk'].includes(configuration.storageProvider))
|
||||
|| typeof configuration.automaticBackups?.idleCheckpoint !== 'boolean'
|
||||
|| !finite(configuration.automaticBackups?.intervalMinutes) || configuration.automaticBackups.intervalMinutes < 1) {
|
||||
throw new Error('sleep_qualification_configuration_invalid');
|
||||
}
|
||||
return { automaticBackups: { intervalMinutes: configuration.automaticBackups.intervalMinutes,
|
||||
idleCheckpoint: configuration.automaticBackups.idleCheckpoint }, announcements: configuration.announcements,
|
||||
telemetry: Boolean(configuration.telemetryOrigin), otel: { enabled: Boolean(configuration.otelOrigin), protocol: 'http/json' }, serveUi: configuration.serveUi, managedInstance: configuration.managedInstance ?? false,
|
||||
deploymentMode: configuration.deploymentMode ?? 'local_trusted', storageProvider: configuration.storageProvider ?? 'local_disk' };
|
||||
}
|
||||
export const sleepConfigurationDigest = configuration => createHash('sha256').update(JSON.stringify(logicalSleepConfiguration(configuration))).digest('hex');
|
||||
|
||||
/** Finish after observations, then validate the exact serialized local receipt. */
|
||||
export function finalizeLocalSleepReceipt(observations, expected, now = () => new Date().toISOString()) {
|
||||
const receipt = { ...observations, finishedAt: now() };
|
||||
const report = evaluateSleepQualification(receipt, expected);
|
||||
if (report.layers.workerQuiet.verdict !== 'VERIFIED' || report.layers.eligibility.verdict !== 'VERIFIED') {
|
||||
throw new Error('sleep_qualification_local_evidence_invalid');
|
||||
}
|
||||
return { receipt, report };
|
||||
}
|
||||
|
||||
/** Evaluate this campaign's measured boundaries. A local run cannot certify a provider. */
|
||||
export function evaluateSleepQualification(receipt, expected) {
|
||||
if (!receipt || receipt.version !== 1 || !expected || !sha(expected.appSha)
|
||||
|| !sha(expected.cloudSha) || typeof expected.target !== 'string' || !expected.target
|
||||
|| typeof expected.configDigest !== 'string' || !/^[a-f0-9]{64}$/.test(expected.configDigest)
|
||||
|| !stamp(receipt.startedAt) || !stamp(receipt.finishedAt)
|
||||
|| Date.parse(receipt.finishedAt) < Date.parse(receipt.startedAt)
|
||||
|| ['appSha', 'cloudSha', 'target', 'configDigest'].some(key => receipt[key] !== expected[key])) {
|
||||
throw new Error('sleep_qualification_identity_or_window_invalid');
|
||||
}
|
||||
if (sleepConfigurationDigest(receipt.configuration) !== expected.configDigest) throw new Error('sleep_qualification_configuration_digest_invalid');
|
||||
const origins = receipt.collectorOrigins;
|
||||
if (!origins || origins.telemetry !== receipt.configuration.telemetryOrigin || origins.otel !== receipt.configuration.otelOrigin
|
||||
|| (expected.collectorOrigins && (origins.telemetry !== expected.collectorOrigins.telemetry || origins.otel !== expected.collectorOrigins.otel))) {
|
||||
throw new Error('sleep_qualification_collector_origins_invalid');
|
||||
}
|
||||
const output = Object.fromEntries(layers.map(layer => [layer, result('INCONCLUSIVE', 'No evidence for this layer')]));
|
||||
const inRun = value => stamp(value) && Date.parse(value) >= Date.parse(receipt.startedAt) && Date.parse(value) <= Date.parse(receipt.finishedAt);
|
||||
const quiet = receipt.quiet;
|
||||
if (quiet) {
|
||||
const observedFeatures = configuredSleepFeatures(receipt);
|
||||
const valid = inRun(quiet.startedAt) && inRun(quiet.finishedAt) && finite(quiet.elapsedMs)
|
||||
&& Date.parse(quiet.finishedAt) - Date.parse(quiet.startedAt) >= 90_000 && quiet.elapsedMs >= 90_000
|
||||
&& quiet.clock === 'real' && finite(quiet.sqlQueries) && finite(quiet.connectionsOpened)
|
||||
&& finite(quiet.connectionsRemaining) && finite(quiet.nonDatabaseRequests) && finite(quiet.nonDatabaseBytes)
|
||||
&& observedNoIncomingHttp(quiet.observerRequests, quiet)
|
||||
&& inRun(quiet.observerRequests.before.observedAt) && inRun(quiet.observerRequests.after.observedAt)
|
||||
&& observedFeatures.telemetry && observedFeatures.announcements && observedFeatures.otel
|
||||
&& Object.entries(observedFeatures).every(([key, value]) => receipt.features?.[key] === value)
|
||||
&& receipt.features?.telemetry === true && receipt.features?.announcements === true
|
||||
&& receipt.features?.automaticBackups === true && validBackupCheckpoint(receipt.backupCheckpoint, inRun, quiet.finishedAt)
|
||||
&& receipt.features?.otel === true && receipt.features?.exportedSpans > 0;
|
||||
const idle = valid && quiet.sqlQueries === 0 && quiet.connectionsOpened === 0 && quiet.connectionsRemaining === 0
|
||||
&& quiet.nonDatabaseRequests === 0 && quiet.nonDatabaseBytes === 0;
|
||||
output.workerQuiet = result(idle ? 'VERIFIED' : 'NOT VERIFIED', idle ? 'Real configured quiet window, including closed DB connections' : 'Configured real quiet window missing, contaminated, or active');
|
||||
}
|
||||
const eligibility = receipt.eligibility;
|
||||
if (eligibility) {
|
||||
const valid = inRun(eligibility.observedAt) && eligibility.backgroundWork === 'none'
|
||||
&& eligibility.ownedHold === true && eligibility.unchangedGeneration === true
|
||||
&& finite(eligibility.apiCooldownElapsedMs) && eligibility.apiCooldownElapsedMs >= 600_000;
|
||||
output.eligibility = result(valid ? 'VERIFIED' : 'NOT VERIFIED', valid ? 'Owned stable safety proof after real API activity cooldown' : 'Safety proof or real API activity cooldown not established');
|
||||
}
|
||||
const provider = receipt.provider;
|
||||
if (provider) {
|
||||
if (!provider.live || provider.target !== expected.target || provider.configDigest !== expected.configDigest
|
||||
|| provider.appSha !== expected.appSha || provider.cloudSha !== expected.cloudSha
|
||||
|| !inRun(provider.observedAt)) {
|
||||
throw new Error('sleep_qualification_provider_identity_invalid');
|
||||
}
|
||||
const untouched = Array.isArray(provider.observerRequests)
|
||||
&& provider.observerRequests.every(entry => entry.boundary === 'provider-control-plane' && (entry.method === 'GET' || (entry.provider === 'railway' && entry.method === 'POST' && entry.operation === 'query-only')));
|
||||
const endpoint = provider.neon;
|
||||
if (untouched && endpoint?.identityVerified === true && endpoint.currentState === 'idle' && endpoint.pendingState === null) {
|
||||
output.databaseSuspension = result('VERIFIED', 'Exact endpoint idle from live Neon control-plane metadata');
|
||||
}
|
||||
if (untouched && endpoint?.identityVerified === true && (endpoint.currentState === 'active'
|
||||
|| (endpoint.currentState === 'idle' && endpoint.pendingState === 'active'))) {
|
||||
output.databaseSuspension = result('NOT VERIFIED', 'Exact Neon endpoint is active or becoming active');
|
||||
}
|
||||
const services = provider.railway?.services;
|
||||
if (untouched && provider.railway?.identityVerified === true && provider.railway.state === 'OBSERVED' && Array.isArray(services) && services.length > 0
|
||||
&& services.every(service => service.identityVerified === true && service.state === 'OBSERVED' && service.allDeploymentsStopped === true
|
||||
&& typeof service.backupsEnabled === 'boolean' && (service.backupsEnabled === false || service.backupStorageVerified === true))) {
|
||||
output.hostSuspension = result('VERIFIED', 'Every exact owned service has stopped deployments');
|
||||
}
|
||||
if (untouched && provider.railway?.identityVerified === true && Array.isArray(services)
|
||||
&& services.some(service => service.identityVerified === true && service.state === 'OBSERVED' && service.allDeploymentsStopped === false)) {
|
||||
output.hostSuspension = result('NOT VERIFIED', 'An exact observed owned service still has running deployments');
|
||||
}
|
||||
const storage = provider.storage;
|
||||
if (output.hostSuspension.verdict === 'VERIFIED' && storage?.identityVerified === true
|
||||
&& storage.volumeId && storage.beforeVolumeId === storage.volumeId && storage.afterVolumeId === storage.volumeId
|
||||
&& storage.mountPath === '/paperclip' && storage.backupsEnabledBefore === true && storage.backupsEnabledAfter === true
|
||||
&& /^[a-f0-9]{64}$/.test(storage.sentinelBefore ?? '') && storage.sentinelBefore === storage.sentinelAfter
|
||||
&& inRun(storage.beforeAt) && inRun(storage.afterAt) && Date.parse(storage.beforeAt) < Date.parse(provider.observedAt)
|
||||
&& Date.parse(storage.afterAt) > Date.parse(provider.observedAt)) {
|
||||
output.backupPersistence = result('VERIFIED', 'Attached volume and fsynced sentinel survive owned stop/wake with backups enabled');
|
||||
}
|
||||
}
|
||||
for (const [layer, kind] of [['inboundWake', 'inbound'], ['timedWake', 'deadline']]) {
|
||||
const wake = receipt[layer];
|
||||
if (!wake) continue;
|
||||
const sleeping = output.databaseSuspension.verdict === 'VERIFIED' && output.hostSuspension.verdict === 'VERIFIED';
|
||||
const chronology = provider && inRun(wake.stimulusAt) && inRun(wake.finishedAt)
|
||||
&& Date.parse(wake.stimulusAt) > Date.parse(provider.observedAt) && Date.parse(wake.finishedAt) >= Date.parse(wake.stimulusAt);
|
||||
const alarm = kind !== 'deadline' || (inRun(wake.alarmPersistedAt)
|
||||
&& Date.parse(wake.alarmPersistedAt) < Date.parse(provider.observedAt) && wake.sameFinalGeneration === true);
|
||||
if (!sleeping) { output[layer] = result('INCONCLUSIVE', 'Exact provider sleep and known backup policy have not been established'); continue; }
|
||||
const verified = sleeping && chronology && alarm && wake.ordinaryProducer === true
|
||||
&& wake.authorizationVerified === true && wake.persistedOutcomeVerified === true && wake.duplicateEffects === 0;
|
||||
output[layer] = result(verified ? 'VERIFIED' : 'NOT VERIFIED', verified ? `Useful authorized ${kind} wake after provider sleep` : `Missing provider sleep, chronology, authorization or useful ${kind} outcome`);
|
||||
}
|
||||
if (receipt.returnToIdle) {
|
||||
const presentWakes = ['inboundWake', 'timedWake'].filter(layer => Object.hasOwn(receipt, layer));
|
||||
const everyWakeInRun = presentWakes.every(layer => inRun(receipt[layer]?.stimulusAt) && inRun(receipt[layer]?.finishedAt)
|
||||
&& Date.parse(receipt[layer].finishedAt) >= Date.parse(receipt[layer].stimulusAt));
|
||||
const lastWake = Math.max(...presentWakes.map(layer => Date.parse(receipt[layer]?.finishedAt)));
|
||||
const idle = receipt.returnToIdle;
|
||||
const usefulWake = ['inboundWake', 'timedWake'].some(layer => output[layer].verdict === 'VERIFIED');
|
||||
const valid = usefulWake && everyWakeInRun && Number.isFinite(lastWake) && inRun(idle.observedAt) && Date.parse(idle.observedAt) > lastWake
|
||||
&& inRun(idle.quietStartedAt) && inRun(idle.quietFinishedAt)
|
||||
&& Date.parse(idle.quietStartedAt) >= lastWake && Date.parse(idle.quietFinishedAt) <= Date.parse(idle.observedAt)
|
||||
&& Date.parse(idle.quietFinishedAt) - Date.parse(idle.quietStartedAt) >= 90_000
|
||||
&& finite(idle.freshQuietWindowMs) && idle.freshQuietWindowMs >= 90_000
|
||||
&& idle.freshQuietWindowMs <= Date.parse(idle.quietFinishedAt) - Date.parse(idle.quietStartedAt) && idle.backgroundWork === 'none' && idle.ownedHold === true;
|
||||
output.returnToIdle = result(valid ? 'VERIFIED' : 'NOT VERIFIED', valid ? 'Fresh quiet owned proof after useful wake' : 'No fresh quiet proof after useful wake');
|
||||
}
|
||||
return { version: 1, target: expected.target, appSha: expected.appSha, cloudSha: expected.cloudSha, configDigest: expected.configDigest, layers: output };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const [receiptPath, expectedPath, outputPath] = process.argv.slice(2);
|
||||
if (!receiptPath || !expectedPath || !outputPath) throw new Error('Usage: node scripts/sleep-qualification/evidence.mjs receipt.json expected.json output.json');
|
||||
const report = evaluateSleepQualification(JSON.parse(await readFile(receiptPath, 'utf8')), JSON.parse(await readFile(expectedPath, 'utf8')));
|
||||
await writeFile(outputPath, JSON.stringify(report, null, 2) + '\n', { mode: 0o600 });
|
||||
process.stdout.write(JSON.stringify(report) + '\n');
|
||||
if (Object.values(report.layers).some(layer => layer.verdict === 'NOT VERIFIED')) process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { lstat, mkdtemp, mkdir, readFile, readlink, rename, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// Explicit opt-in test setup. No manifests, lockfiles or other worktrees change.
|
||||
const server = fileURLToPath(new URL('../../server/', import.meta.url));
|
||||
export const otelPeerNames = ['@opentelemetry/sdk-node', '@opentelemetry/auto-instrumentations-node', '@opentelemetry/exporter-trace-otlp-http', '@opentelemetry/resources', '@opentelemetry/semantic-conventions'];
|
||||
const ledgerPath = root => path.join(root, 'node_modules', '.paperclip-sleep-otel-links.json');
|
||||
async function entryAt(destination) {
|
||||
try { return await lstat(destination); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
}
|
||||
async function versionAt(root, name) {
|
||||
try { return JSON.parse(await readFile(path.join(root, 'node_modules', name, 'package.json'), 'utf8')).version; }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; return undefined; }
|
||||
}
|
||||
async function readOwnership(root) {
|
||||
const file = ledgerPath(root), entry = await entryAt(file);
|
||||
if (!entry) return [];
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error('OTel ownership ledger must be a regular file');
|
||||
const record = JSON.parse(await readFile(file, 'utf8'));
|
||||
if (record.version !== 1 || record.serverDirectory !== root || !Array.isArray(record.links)
|
||||
|| record.links.length > otelPeerNames.length || new Set(record.links.map(link => link.name)).size !== record.links.length
|
||||
|| record.links.some(link => !otelPeerNames.includes(link.name) || typeof link.target !== 'string'
|
||||
|| !path.isAbsolute(link.target) || path.resolve(link.target) !== link.target
|
||||
|| !link.target.endsWith(path.join('node_modules', link.name)))) throw new Error('Invalid OTel ownership ledger');
|
||||
return record.links;
|
||||
}
|
||||
async function writeOwnership(root, links) {
|
||||
const file = ledgerPath(root);
|
||||
if (!links.length) { await rm(file, { force: true }); return; }
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, JSON.stringify({ version: 1, serverDirectory: root, links }, null, 2), { flag: 'wx' });
|
||||
await rename(temporary, file);
|
||||
} finally { await rm(temporary, { force: true }); }
|
||||
}
|
||||
async function ownsLink(destination, record) {
|
||||
const entry = await entryAt(destination);
|
||||
return Boolean(record && entry?.isSymbolicLink()
|
||||
&& path.resolve(path.dirname(destination), await readlink(destination)) === record.target);
|
||||
}
|
||||
async function installWithNpm(directory) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn('npm', ['install', '--prefix', directory, '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline'], { stdio: 'inherit', shell: false });
|
||||
child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error(`OTel fixture installation failed (${code})`)));
|
||||
});
|
||||
}
|
||||
|
||||
/** Only the exact recorded symlink may be removed; install prefixes retain evidence. */
|
||||
export async function cleanupSleepOtelLinks({ serverDirectory = server } = {}) {
|
||||
const root = path.resolve(serverDirectory), records = await readOwnership(root), remaining = [], removed = [], errors = [];
|
||||
for (const record of records) {
|
||||
const destination = path.join(root, 'node_modules', record.name);
|
||||
if (!await entryAt(destination)) continue;
|
||||
if (!await ownsLink(destination, record)) {
|
||||
remaining.push(record); errors.push(new Error(`Refusing to clean unrelated ${record.name}`)); continue;
|
||||
}
|
||||
await unlink(destination); removed.push(record.name);
|
||||
}
|
||||
await writeOwnership(root, remaining);
|
||||
if (errors.length) throw new AggregateError(errors, 'Some OTel destinations no longer match owned links');
|
||||
return { removed, installPrefixes: [...new Set(records.map(record => path.dirname(path.dirname(path.dirname(record.target)))))], boundary: 'Only recorded matching symlinks removed; temporary install prefixes are not deleted' };
|
||||
}
|
||||
|
||||
export async function setupSleepOtelPeers({ serverDirectory = server, temporaryRoot = tmpdir(), install = installWithNpm } = {}) {
|
||||
const root = path.resolve(serverDirectory), manifest = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
const owned = await readOwnership(root), missing = [];
|
||||
for (const name of otelPeerNames) {
|
||||
const exact = manifest.peerDependencies[name];
|
||||
if (!/^\d+\.\d+\.\d+$/.test(exact ?? '')) throw new Error(`Exact existing peer version required for ${name}`);
|
||||
const installed = await versionAt(root, name);
|
||||
if (installed && installed !== exact) throw new Error(`Installed ${name} does not match ${exact}; refusing to replace it`);
|
||||
if (installed) continue;
|
||||
const destination = path.join(root, 'node_modules', name);
|
||||
if (await entryAt(destination) && !await ownsLink(destination, owned.find(link => link.name === name))) {
|
||||
throw new Error(`Refusing to replace unrelated ${name}; restore its target or verify ownership before removing the unrecorded entry`);
|
||||
}
|
||||
missing.push(name);
|
||||
}
|
||||
if (!missing.length) return { linked: [], alreadyInstalled: true };
|
||||
const directory = await mkdtemp(path.join(temporaryRoot, 'paperclip-sleep-otel-'));
|
||||
let linked = 0;
|
||||
try {
|
||||
await writeFile(path.join(directory, 'package.json'), JSON.stringify({ private: true, dependencies: Object.fromEntries(otelPeerNames.map(name => [name, manifest.peerDependencies[name]])) }, null, 2));
|
||||
await install(directory);
|
||||
const resolved = Object.fromEntries(await Promise.all(otelPeerNames.map(async name => [name, await versionAt(directory, name)])));
|
||||
for (const name of otelPeerNames) if (resolved[name] !== manifest.peerDependencies[name]) throw new Error(`Resolved ${name} differs from pinned peer`);
|
||||
const records = [...owned];
|
||||
for (const name of missing) {
|
||||
const destination = path.join(root, 'node_modules', name), previous = owned.find(record => record.name === name);
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
if (await entryAt(destination)) {
|
||||
if (!await ownsLink(destination, previous)) throw new Error(`OTel destination changed; refusing to replace ${name}`);
|
||||
await unlink(destination);
|
||||
}
|
||||
const target = path.join(directory, 'node_modules', name);
|
||||
await symlink(target, destination, 'dir'); linked++;
|
||||
const updated = [...records.filter(record => record.name !== name), { name, target }];
|
||||
try { await writeOwnership(root, updated); }
|
||||
catch (error) {
|
||||
if (await ownsLink(destination, { name, target })) { await unlink(destination); linked--; }
|
||||
throw error;
|
||||
}
|
||||
records.splice(0, records.length, ...updated);
|
||||
}
|
||||
const receipt = { version: 1, directory, packages: resolved, linked: missing, ownershipLedger: ledgerPath(root), cleanup: 'node scripts/sleep-qualification/setup-otel.mjs --cleanup' };
|
||||
await writeFile(path.join(directory, 'qualification-setup.json'), JSON.stringify(receipt, null, 2));
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
// A prefix with published links stays available for explicit owned cleanup.
|
||||
if (!linked) await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length > 1 || (args.length && args[0] !== '--cleanup')) throw new Error('Usage: node scripts/sleep-qualification/setup-otel.mjs [--cleanup]');
|
||||
const receipt = args[0] === '--cleanup' ? await cleanupSleepOtelLinks() : await setupSleepOtelPeers();
|
||||
process.stdout.write(JSON.stringify(receipt) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createActionsReader, readSourceVerification, SourceVerificationFailed, SourceVerificationDeferred } from "./cloud-source-verification.mjs";
|
||||
import { requestDeferredProducer, sourceAdmissionJob, migratorAdmissionJob, createReleaseRerunner } from "./cloud-release-admission.mjs";
|
||||
|
||||
const repository = "paperclipai/paperclip";
|
||||
|
||||
export function runnerWaits(jobs, now = Date.now()) {
|
||||
return jobs.filter(job => ["queued", "in_progress", "completed"].includes(job.status) &&
|
||||
job.conclusion !== "skipped" && job.labels?.length > 0).map(job => ({
|
||||
job: job.name, id: job.id,
|
||||
seconds: Math.max(0, Math.floor(((job.runner_id > 0 && job.started_at ? Date.parse(job.started_at) : now) - Date.parse(job.created_at)) / 1000)),
|
||||
})).filter(job => Number.isFinite(job.seconds));
|
||||
}
|
||||
|
||||
// The write is limited to this canonical master Docker run. Never cancel
|
||||
// another workflow, a tag release, or a run selected from untrusted input.
|
||||
export async function cancelImageRun({ api, cancel, runId, attempt, sha }) {
|
||||
const run = await api(`/repos/${repository}/actions/runs/${runId}`);
|
||||
if (run.id !== runId || run.run_attempt !== attempt || run.head_sha !== sha || run.head_branch !== "master" ||
|
||||
run.repository?.id !== 1170821064 || run.head_repository?.id !== 1170821064 ||
|
||||
run.path !== ".github/workflows/docker.yml" || !["push", "workflow_dispatch"].includes(run.event)) {
|
||||
throw new Error("Refusing to cancel a different image workflow identity.");
|
||||
}
|
||||
if (run.status !== "completed") await cancel(`/repos/${repository}/actions/runs/${runId}/cancel`);
|
||||
}
|
||||
|
||||
async function cancelFailedVerification({ api, cancel, sha }, failure) {
|
||||
const run = await api(`/repos/${repository}/actions/runs/${failure.runId}`);
|
||||
// A concurrent retry owns the new attempt; a late observer leaves it alone.
|
||||
if (run.run_attempt !== failure.attempt) return;
|
||||
if (run.id !== failure.runId || run.head_sha !== sha || run.head_branch !== "master" || run.event !== "push" ||
|
||||
run.repository?.full_name !== repository || run.head_repository?.full_name !== repository ||
|
||||
run.path !== ".github/workflows/cloud-readiness.yml") throw new Error("Failed verification identity changed.");
|
||||
if (run.status !== "completed") await cancel(`/repos/${repository}/actions/runs/${failure.runId}/cancel`);
|
||||
}
|
||||
|
||||
export async function watchReleaseSource({ sha, runId, attempt, api, cancel, rerun, now = Date.now,
|
||||
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), log = console.log,
|
||||
timeoutMs = 40 * 60_000, intervalMs = 30_000, requireProof = false, summary = async () => {} }) {
|
||||
if (!/^[a-f0-9]{40}$/.test(sha ?? "") || !Number.isSafeInteger(runId) || runId < 1 ||
|
||||
!Number.isSafeInteger(attempt) || attempt < 1) throw new Error("Invalid release identity.");
|
||||
const deadline = now() + timeoutMs;
|
||||
const warned = new Set();
|
||||
const requested = new Set();
|
||||
let migratorAdmitted = !rerun;
|
||||
const reportWaits = async jobs => {
|
||||
for (const wait of runnerWaits(jobs, now())) {
|
||||
if (wait.seconds > 60 && !warned.has(wait.id)) {
|
||||
warned.add(wait.id);
|
||||
log(`::warning title=Release runner waited over 60 seconds::Job ${wait.id} waited ${wait.seconds}s for a runner.`);
|
||||
await summary(`Release runner wait: ${wait.job} — ${wait.seconds}s (job ${wait.id}).\n`);
|
||||
}
|
||||
}
|
||||
};
|
||||
while (now() < deadline) {
|
||||
if (!migratorAdmitted) {
|
||||
migratorAdmitted = await requestDeferredProducer({ file: "cloud-migrator-artifacts.yml", sha, admissionJob: migratorAdmissionJob, api, rerun, requested }) !== "pending";
|
||||
}
|
||||
const batch = await api(`/repos/${repository}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100`);
|
||||
if (!Array.isArray(batch.jobs) || batch.total_count > 100) throw new Error("Incomplete image job listing.");
|
||||
await reportWaits(batch.jobs);
|
||||
try {
|
||||
const proof = await readSourceVerification(sha, api, { failFast: true, onJobs: reportWaits });
|
||||
if (proof && migratorAdmitted) {
|
||||
await summary(`Exact source verified: ${sha}; readiness run ${proof.runId}, attempt ${proof.attempt}.\n`);
|
||||
return proof;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SourceVerificationDeferred && rerun) {
|
||||
await requestDeferredProducer({ file: "cloud-readiness.yml", sha, admissionJob: sourceAdmissionJob, api, rerun, requested });
|
||||
} else {
|
||||
if (error instanceof SourceVerificationFailed) {
|
||||
await cancelFailedVerification({ api, cancel, sha }, error);
|
||||
await cancelImageRun({ api, cancel, runId, attempt, sha });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await sleep(Math.min(intervalMs, Math.max(0, deadline - now())));
|
||||
}
|
||||
// This early failure observer is not the publication deadline. Healthy
|
||||
// checks can outlive a Spot worker; a separate exact-source gate still
|
||||
// waits for proof before publishing any image.
|
||||
if (requireProof) throw new Error("Exact-source verification did not become ready before the publication deadline.");
|
||||
await summary("Source monitoring window ended; final publication still requires exact-source verification.\n");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (!token || process.env.GITHUB_REPOSITORY !== repository || process.env.GITHUB_REF !== "refs/heads/master") throw new Error("Canonical master token required.");
|
||||
const requireProof = process.argv.includes("--require-proof");
|
||||
const checkProof = process.argv.includes("--check-proof");
|
||||
const api = createActionsReader({ token });
|
||||
const proof = checkProof ? await readSourceVerification(process.env.GITHUB_SHA, api, { failFast: true }) : await watchReleaseSource({ requireProof, sha: process.env.GITHUB_SHA, runId: Number(process.env.GITHUB_RUN_ID), attempt: Number(process.env.GITHUB_RUN_ATTEMPT),
|
||||
api,
|
||||
rerun: createReleaseRerunner(token),
|
||||
cancel: async path => {
|
||||
const response = await fetch(`https://api.github.com${path}`, { method: "POST", redirect: "error",
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, signal: AbortSignal.timeout(30_000) });
|
||||
if (!response.ok) throw new Error(`Could not cancel failed-source image work: HTTP ${response.status}.`);
|
||||
},
|
||||
summary: text => process.env.GITHUB_STEP_SUMMARY ? appendFile(process.env.GITHUB_STEP_SUMMARY, text) : Promise.resolve(),
|
||||
});
|
||||
if (checkProof && !proof) throw new Error("Exact-source proof changed before publication.");
|
||||
if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `verified=${Boolean(proof)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { it } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, readlink, readdir, lstat, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { cleanupSleepOtelLinks, otelPeerNames, setupSleepOtelPeers } from '../../scripts/sleep-qualification/setup-otel.mjs';
|
||||
|
||||
async function fixture(run) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'sleep-otel-setup-test-'));
|
||||
const serverDirectory = path.join(directory, 'server'), temporaryRoot = path.join(directory, 'installs');
|
||||
await mkdir(serverDirectory); await mkdir(temporaryRoot);
|
||||
await writeFile(path.join(serverDirectory, 'package.json'), JSON.stringify({ peerDependencies: Object.fromEntries(otelPeerNames.map(name => [name, '1.2.3'])) }));
|
||||
let installs = 0;
|
||||
const install = async prefix => {
|
||||
installs++;
|
||||
const manifest = JSON.parse(await readFile(path.join(prefix, 'package.json'), 'utf8'));
|
||||
for (const [name, version] of Object.entries(manifest.dependencies)) {
|
||||
const packageDirectory = path.join(prefix, 'node_modules', name);
|
||||
await mkdir(packageDirectory, { recursive: true });
|
||||
await writeFile(path.join(packageDirectory, 'package.json'), JSON.stringify({ version }));
|
||||
}
|
||||
};
|
||||
const options = { serverDirectory, temporaryRoot, install };
|
||||
try { await run({ directory, options, install, installs: () => installs, destination: name => path.join(serverDirectory, 'node_modules', name) }); }
|
||||
finally { await rm(directory, { recursive: true, force: true }); }
|
||||
}
|
||||
it('reruns after losing its temporary peers and cleans only its recorded matching links', async () => fixture(async f => {
|
||||
const first = await setupSleepOtelPeers(f.options);
|
||||
assert.equal(first.linked.length, 5); assert.equal(f.installs(), 1);
|
||||
assert.equal((await setupSleepOtelPeers(f.options)).alreadyInstalled, true); assert.equal(f.installs(), 1);
|
||||
await rm(first.directory, { recursive: true });
|
||||
assert.equal((await lstat(f.destination(otelPeerNames[0]))).isSymbolicLink(), true);
|
||||
const repaired = await setupSleepOtelPeers(f.options);
|
||||
assert.equal(f.installs(), 2); assert.notEqual(repaired.directory, first.directory);
|
||||
for (const name of otelPeerNames) assert.equal(await readlink(f.destination(name)), path.join(repaired.directory, 'node_modules', name));
|
||||
const cleaned = await cleanupSleepOtelLinks(f.options); assert.equal(cleaned.removed.length, 5);
|
||||
for (const name of otelPeerNames) await assert.rejects(lstat(f.destination(name)), { code: 'ENOENT' });
|
||||
assert.ok(await readFile(path.join(repaired.directory, 'qualification-setup.json'), 'utf8')); // Evidence retained.
|
||||
}));
|
||||
it('preserves unowned dangling links, incomplete directories and mismatched installed packages', async () => {
|
||||
for (const kind of ['dangling', 'directory', 'mismatch']) await fixture(async f => {
|
||||
const destination = f.destination(otelPeerNames[0]); await mkdir(path.dirname(destination), { recursive: true });
|
||||
if (kind === 'dangling') await symlink(path.join(f.directory, 'foreign-missing'), destination);
|
||||
else { await mkdir(destination); await writeFile(path.join(destination, kind === 'mismatch' ? 'package.json' : 'keep.txt'), kind === 'mismatch' ? JSON.stringify({ version: '9.9.9' }) : 'foreign'); }
|
||||
await assert.rejects(setupSleepOtelPeers(f.options), /refusing|Refusing/); assert.equal(f.installs(), 0);
|
||||
if (kind === 'dangling') assert.equal(await readlink(destination), path.join(f.directory, 'foreign-missing'));
|
||||
else assert.ok(await readFile(path.join(destination, kind === 'mismatch' ? 'package.json' : 'keep.txt'), 'utf8'));
|
||||
});
|
||||
});
|
||||
it('keeps healthy unrelated packages and refuses cleanup after an owned link is replaced', async () => fixture(async f => {
|
||||
const destination = f.destination(otelPeerNames[0]); await mkdir(destination, { recursive: true });
|
||||
await writeFile(path.join(destination, 'package.json'), JSON.stringify({ version: '1.2.3' }));
|
||||
const receipt = await setupSleepOtelPeers(f.options); assert.equal(receipt.linked.length, 4);
|
||||
const replaced = f.destination(otelPeerNames[1]); await unlink(replaced);
|
||||
const foreign = path.join(f.directory, 'foreign'); await mkdir(foreign); await writeFile(path.join(foreign, 'keep.txt'), 'foreign');
|
||||
await symlink(foreign, replaced);
|
||||
await assert.rejects(cleanupSleepOtelLinks(f.options), /no longer match/);
|
||||
assert.equal(await readlink(replaced), foreign); assert.equal(await readFile(path.join(foreign, 'keep.txt'), 'utf8'), 'foreign');
|
||||
assert.equal(JSON.parse(await readFile(path.join(destination, 'package.json'), 'utf8')).version, '1.2.3');
|
||||
}));
|
||||
it('preserves an entry introduced during installation and removes an unpublished failed install prefix', async () => fixture(async f => {
|
||||
await assert.rejects(setupSleepOtelPeers({ ...f.options, install: async prefix => {
|
||||
await f.install(prefix); const destination = f.destination(otelPeerNames[0]); await mkdir(destination, { recursive: true }); await writeFile(path.join(destination, 'keep.txt'), 'new foreign entry');
|
||||
} }), /destination changed/);
|
||||
assert.equal(await readFile(path.join(f.destination(otelPeerNames[0]), 'keep.txt'), 'utf8'), 'new foreign entry');
|
||||
assert.equal((await readdir(f.options.temporaryRoot)).length, 0);
|
||||
}));
|
||||
@@ -0,0 +1,233 @@
|
||||
import { it as test } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { configuredSleepFeatures, evaluateSleepQualification, observeConfiguredSignals, observeIncomingHttpWindow, sleepConfigurationDigest, finalizeLocalSleepReceipt } from '../../scripts/sleep-qualification/evidence.mjs';
|
||||
const configuration = { automaticBackups: { intervalMinutes: 60, idleCheckpoint: true }, announcements: true, telemetryOrigin: 'http://127.0.0.1:4318/telemetry', otelOrigin: 'http://127.0.0.1:4318', serveUi: true };
|
||||
// Independent preflight contract, not copied from the receipt/digest implementation.
|
||||
const configDigest = createHash('sha256').update(JSON.stringify({ automaticBackups: { intervalMinutes: 60, idleCheckpoint: true }, announcements: true, telemetry: true, otel: { enabled: true, protocol: 'http/json' }, serveUi: true, managedInstance: false, deploymentMode: 'local_trusted', storageProvider: 'local_disk' })).digest('hex');
|
||||
const expected = { appSha: 'a'.repeat(40), cloudSha: 'b'.repeat(40), target: 'owned-fixture', configDigest };
|
||||
function fixture() {
|
||||
return { version: 1, ...expected, configuration: structuredClone(configuration), collectorOrigins: { telemetry: configuration.telemetryOrigin, otel: configuration.otelOrigin },
|
||||
announcement: { intercepted: true, requests: ['https://pages.paperclip.ing/announcements/v1/current.json'], unmatched: [] },
|
||||
signalObservation: { telemetryRequests: 1, traceRequests: 1, exportedSpans: 1, qualificationSpans: 1, metricRequests: 0, logRequests: 0, malformedTraceRequests: 0, unexpectedRequests: 0 },
|
||||
backupCheckpoint: { owner: 'normal-idle-checkpoint', startedAt: '2099-01-01T00:12:31Z', observedAt: '2099-01-01T00:13:00Z', wakeMarkerObserved: true, failureObserved: false, archives: [{ name: 'owned.sql.gz', bytes: 128, sqlBytes: 256, sha256: 'd'.repeat(64), gzipVerified: true, fixtureIdentifierFound: true, createdAt: '2099-01-01T00:12:45Z', createdAtSource: 'birthtime' }] }, startedAt: '2099-01-01T00:00:00Z', finishedAt: '2099-01-01T01:00:00Z',
|
||||
features: { telemetry: true, announcements: true, automaticBackups: true, otel: true, exportedSpans: 1 },
|
||||
quiet: { startedAt: '2099-01-01T00:11:00Z', finishedAt: '2099-01-01T00:12:30Z', elapsedMs: 90_000, clock: 'real', sqlQueries: 0, connectionsOpened: 0, connectionsRemaining: 0, nonDatabaseRequests: 0, nonDatabaseBytes: 0,
|
||||
observerRequests: observeIncomingHttpWindow(
|
||||
{ source: 'node-http-server-events', captureId: '11111111-1111-4111-8111-111111111111', installed: true, valid: true, requests: 12, observedAt: '2099-01-01T00:11:00Z' },
|
||||
{ source: 'node-http-server-events', captureId: '11111111-1111-4111-8111-111111111111', installed: true, valid: true, requests: 12, observedAt: '2099-01-01T00:12:30Z' }) },
|
||||
eligibility: { observedAt: '2099-01-01T00:13:00Z', backgroundWork: 'none', ownedHold: true, unchangedGeneration: true, apiCooldownElapsedMs: 600_000 } };
|
||||
}
|
||||
function deployed() {
|
||||
const f = fixture();
|
||||
f.provider = { ...expected, live: true, observedAt: '2099-01-01T00:14:00Z', observerRequests: [{ method: 'GET', boundary: 'provider-control-plane' }],
|
||||
neon: { identityVerified: true, currentState: 'idle', pendingState: null },
|
||||
railway: { identityVerified: true, state: 'OBSERVED', services: [{ identityVerified: true, state: 'OBSERVED', backupsEnabled: false, backupStorageVerified: false, allDeploymentsStopped: true }] } };
|
||||
return f;
|
||||
}
|
||||
test('local configured quiet leaves every provider and wake claim inconclusive', () => {
|
||||
const r = evaluateSleepQualification(fixture(), expected).layers;
|
||||
assert.equal(r.workerQuiet.verdict, 'VERIFIED'); assert.equal(r.eligibility.verdict, 'VERIFIED');
|
||||
for (const key of ['hostSuspension', 'databaseSuspension', 'backupPersistence', 'inboundWake', 'timedWake']) assert.equal(r[key].verdict, 'INCONCLUSIVE');
|
||||
});
|
||||
test('wrong source, target and configuration evidence cannot be combined', () => {
|
||||
for (const key of Object.keys(expected)) { const f = fixture(); f[key] = 'wrong'; assert.throws(() => evaluateSleepQualification(f, expected), /identity/); }
|
||||
const f = deployed(); f.provider.target = 'foreign'; assert.throws(() => evaluateSleepQualification(f, expected), /provider_identity/);
|
||||
});
|
||||
test('fake time, a no-op exporter, open connections, observer health requests and a shortened cooldown fail', () => {
|
||||
for (const change of [f => f.quiet.clock = 'qualified', f => f.features.exportedSpans = 0, f => f.quiet.connectionsRemaining = 1,
|
||||
f => { f.quiet.observerRequests.after.requests++; f.quiet.observerRequests.requests++; }, f => f.quiet.nonDatabaseBytes = 1, f => f.quiet.elapsedMs = 89_999]) {
|
||||
const f = fixture(); change(f); assert.equal(evaluateSleepQualification(f, expected).layers.workerQuiet.verdict, 'NOT VERIFIED');
|
||||
}
|
||||
const f = fixture(); f.eligibility.apiCooldownElapsedMs = 599_999; assert.equal(evaluateSleepQualification(f, expected).layers.eligibility.verdict, 'NOT VERIFIED');
|
||||
});
|
||||
test('incoming HTTP observation must be installed, intact and cover the whole window; legacy empty lists are not evidence', () => {
|
||||
const changes = [f => f.quiet.observerRequests = [], f => delete f.quiet.observerRequests,
|
||||
f => f.quiet.observerRequests.before.installed = false, f => f.quiet.observerRequests.after.installed = false,
|
||||
f => f.quiet.observerRequests.before.valid = false, f => f.quiet.observerRequests.after.valid = false,
|
||||
f => delete f.quiet.observerRequests.before, f => delete f.quiet.observerRequests.after,
|
||||
f => f.quiet.observerRequests.before.source = 'harness-api-only', f => f.quiet.observerRequests.after.source = 'unknown',
|
||||
f => f.quiet.observerRequests.after.captureId = '22222222-2222-4222-8222-222222222222',
|
||||
f => f.quiet.observerRequests.before.captureId = '',
|
||||
f => f.quiet.observerRequests.before.observedAt = '2099-01-01T00:11:00.001Z',
|
||||
f => f.quiet.observerRequests.after.observedAt = '2099-01-01T00:12:29.999Z',
|
||||
f => f.quiet.observerRequests.before.observedAt = '2098-12-31T23:59:59Z',
|
||||
f => f.quiet.observerRequests.after.observedAt = '2099-01-01T01:00:01Z',
|
||||
f => f.quiet.observerRequests.after.observedAt = 'invalid',
|
||||
f => f.quiet.observerRequests.after.requests--,
|
||||
// A forged zero cannot hide an actual request while SQL and outbound counters remain zero.
|
||||
f => f.quiet.observerRequests.after.requests++,
|
||||
f => f.quiet.observerRequests.requests = 1];
|
||||
for (const point of ['before', 'after']) {
|
||||
for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '12']) {
|
||||
changes.push(f => f.quiet.observerRequests[point].requests = value);
|
||||
}
|
||||
}
|
||||
for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '0']) changes.push(f => f.quiet.observerRequests.requests = value);
|
||||
for (const change of changes) {
|
||||
const f = fixture(); change(f);
|
||||
assert.equal(f.quiet.sqlQueries, 0); assert.equal(f.quiet.nonDatabaseRequests, 0);
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.workerQuiet.verdict, 'NOT VERIFIED');
|
||||
}
|
||||
});
|
||||
test('configured feature claims require complete announcement and integer collector observations', () => {
|
||||
const changes = [f => delete f.announcement, f => f.announcement.intercepted = false,
|
||||
f => f.announcement.requests = [], f => f.announcement.requests = ['https://foreign.example/feed'],
|
||||
f => f.announcement.unmatched.push('https://foreign.example/feed'), f => delete f.announcement.unmatched,
|
||||
f => delete f.signalObservation, f => f.signalObservation.telemetryRequests = 0,
|
||||
f => f.signalObservation.traceRequests = 0, f => f.signalObservation.exportedSpans = 0,
|
||||
f => f.signalObservation.qualificationSpans = 0, f => f.signalObservation.qualificationSpans = 2,
|
||||
f => f.signalObservation.metricRequests = 1, f => f.signalObservation.logRequests = 1,
|
||||
f => f.signalObservation.malformedTraceRequests = 1, f => f.signalObservation.unexpectedRequests = 1,
|
||||
f => f.features.telemetry = false, f => f.features.announcements = false, f => f.features.otel = false];
|
||||
for (const key of Object.keys(fixture().signalObservation)) {
|
||||
changes.push(f => delete f.signalObservation[key]);
|
||||
for (const value of [-1, 0.5, NaN, Infinity, '1']) changes.push(f => f.signalObservation[key] = value);
|
||||
}
|
||||
for (const change of changes) { const f = fixture(); change(f); assert.equal(evaluateSleepQualification(f, expected).layers.workerQuiet.verdict, 'NOT VERIFIED'); }
|
||||
});
|
||||
test('assembled receipts derive coverage from actual named spans and requests, including unwanted exporters', () => {
|
||||
const requests = [{ path: '/telemetry', body: {} }, { path: '/v1/traces', body: { resourceSpans: [{ scopeSpans: [{ spans: [
|
||||
{ name: 'ordinary-http' }, { name: 'sleep-qualification-stimulus' },
|
||||
] }] }] } }];
|
||||
const f = fixture(); f.signalObservation = observeConfiguredSignals(requests); f.features = configuredSleepFeatures(f);
|
||||
assert.deepEqual(f.signalObservation, { telemetryRequests: 1, traceRequests: 1, exportedSpans: 2, qualificationSpans: 1,
|
||||
metricRequests: 0, logRequests: 0, malformedTraceRequests: 0, unexpectedRequests: 0 });
|
||||
assert.equal(f.features.exportedSpans, 2); // One trace request contained two real span records.
|
||||
assert.equal(finalizeLocalSleepReceipt(f, expected, () => f.finishedAt).report.layers.workerQuiet.verdict, 'VERIFIED');
|
||||
for (const extra of [{ path: '/v1/metrics?batch=1', body: {} }, { path: '/v1/logs', body: {} },
|
||||
{ path: '/unmatched', body: {} }, { path: '/v1/traces', body: { resourceSpans: [{}] } }]) {
|
||||
const changed = fixture(); changed.signalObservation = observeConfiguredSignals([...requests, extra]); changed.features = configuredSleepFeatures(changed);
|
||||
assert.equal(evaluateSleepQualification(changed, expected).layers.workerQuiet.verdict, 'NOT VERIFIED');
|
||||
}
|
||||
const noop = fixture(); noop.signalObservation = observeConfiguredSignals([{ path: '/telemetry', body: {} },
|
||||
{ path: '/v1/traces', body: { resourceSpans: [{ scopeSpans: [{ spans: [{ name: 'other', attributes: [{ value: 'sleep-qualification-stimulus' }] }] }] }] } }]);
|
||||
noop.features = configuredSleepFeatures(noop); assert.equal(noop.signalObservation.qualificationSpans, 0);
|
||||
assert.equal(evaluateSleepQualification(noop, expected).layers.workerQuiet.verdict, 'NOT VERIFIED');
|
||||
});
|
||||
test('registry sleep, incomplete deployment inventory and a failed metadata read cannot prove providers stopped', () => {
|
||||
const f = deployed(); f.provider.neon = { currentState: 'sleeping', identityVerified: true }; f.provider.railway.services = [];
|
||||
let r = evaluateSleepQualification(f, expected).layers; assert.equal(r.databaseSuspension.verdict, 'INCONCLUSIVE'); assert.equal(r.hostSuspension.verdict, 'INCONCLUSIVE');
|
||||
f.provider.neon = { currentState: 'idle', pendingState: 'active', identityVerified: true }; r = evaluateSleepQualification(f, expected).layers; assert.equal(r.databaseSuspension.verdict, 'NOT VERIFIED');
|
||||
});
|
||||
test('useful authorized deadline outcome requires a durable alarm before the exact stop proof', () => {
|
||||
const f = deployed(); f.timedWake = { stimulusAt: '2099-01-01T00:20:00Z', finishedAt: '2099-01-01T00:21:00Z', ordinaryProducer: true, authorizationVerified: true, persistedOutcomeVerified: true, duplicateEffects: 0, sameFinalGeneration: true, alarmPersistedAt: '2099-01-01T00:13:30Z' };
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.timedWake.verdict, 'VERIFIED');
|
||||
for (const change of [x => x.timedWake.alarmPersistedAt = '2099-01-01T00:15:00Z', x => x.timedWake.sameFinalGeneration = false, x => x.timedWake.persistedOutcomeVerified = false, x => x.timedWake.duplicateEffects = 1]) {
|
||||
const x = structuredClone(f); change(x); assert.equal(evaluateSleepQualification(x, expected).layers.timedWake.verdict, 'NOT VERIFIED');
|
||||
}
|
||||
});
|
||||
test('writable path alone cannot establish backup persistence across stop and wake', () => {
|
||||
const f = deployed(); f.provider.storage = { identityVerified: true, volumeId: 'volume', beforeVolumeId: 'volume', afterVolumeId: 'volume', mountPath: '/paperclip', backupsEnabledBefore: true, backupsEnabledAfter: true, sentinelBefore: 'd'.repeat(64), sentinelAfter: 'd'.repeat(64), beforeAt: '2099-01-01T00:13:00Z', afterAt: '2099-01-01T00:25:00Z' };
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.backupPersistence.verdict, 'VERIFIED');
|
||||
for (const change of [x => delete x.provider.storage.volumeId, x => x.provider.storage.afterVolumeId = 'replacement', x => x.provider.storage.backupsEnabledAfter = false, x => x.provider.storage.sentinelAfter = 'e'.repeat(64)]) {
|
||||
const x = structuredClone(f); change(x); assert.equal(evaluateSleepQualification(x, expected).layers.backupPersistence.verdict, 'INCONCLUSIVE');
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown backup policy and unverified enabled storage cannot certify a stopped host or wake', () => {
|
||||
for (const change of [x => delete x.provider.railway.state, x => x.provider.railway.state = 'INCONCLUSIVE',
|
||||
x => delete x.provider.railway.services[0].backupsEnabled, x => x.provider.railway.services[0].backupsEnabled = null,
|
||||
x => x.provider.railway.services[0].state = 'INCONCLUSIVE', x => x.provider.railway.services[0].backupsEnabled = true]) {
|
||||
const f = deployed(); change(f);
|
||||
f.inboundWake = { stimulusAt: '2099-01-01T00:20:00Z', finishedAt: '2099-01-01T00:21:00Z', ordinaryProducer: true, authorizationVerified: true, persistedOutcomeVerified: true, duplicateEffects: 0 };
|
||||
f.timedWake = { ...f.inboundWake, alarmPersistedAt: '2099-01-01T00:13:30Z', sameFinalGeneration: true };
|
||||
const r = evaluateSleepQualification(f, expected).layers;
|
||||
assert.equal(r.hostSuspension.verdict, 'INCONCLUSIVE'); assert.equal(r.inboundWake.verdict, 'INCONCLUSIVE'); assert.equal(r.timedWake.verdict, 'INCONCLUSIVE');
|
||||
}
|
||||
const f = deployed(); assert.equal(evaluateSleepQualification(f, expected).layers.hostSuspension.verdict, 'VERIFIED');
|
||||
f.provider.railway.services[0].backupsEnabled = true; f.provider.railway.services[0].backupStorageVerified = true;
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.hostSuspension.verdict, 'VERIFIED');
|
||||
});
|
||||
|
||||
function woken() {
|
||||
const f = deployed();
|
||||
f.inboundWake = { stimulusAt: '2099-01-01T00:20:00Z', finishedAt: '2099-01-01T00:21:00Z', ordinaryProducer: true, authorizationVerified: true, persistedOutcomeVerified: true, duplicateEffects: 0 };
|
||||
f.returnToIdle = { quietStartedAt: '2099-01-01T00:31:00Z', quietFinishedAt: '2099-01-01T00:32:30Z', observedAt: '2099-01-01T00:33:00Z', freshQuietWindowMs: 90_000, backgroundWork: 'none', ownedHold: true };
|
||||
return f;
|
||||
}
|
||||
test('return to idle requires a real 90-second window after the latest wake', () => {
|
||||
assert.equal(evaluateSleepQualification(woken(), expected).layers.returnToIdle.verdict, 'VERIFIED');
|
||||
for (const finishedAt of ['invalid', '2098-12-31T23:59:59Z', '2099-01-01T01:00:01Z']) {
|
||||
const f = woken(); f.timedWake = { ...f.inboundWake, finishedAt, alarmPersistedAt: '2099-01-01T00:13:30Z', sameFinalGeneration: true };
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.inboundWake.verdict, 'VERIFIED');
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.returnToIdle.verdict, 'NOT VERIFIED');
|
||||
}
|
||||
|
||||
for (const change of [
|
||||
f => f.returnToIdle.quietFinishedAt = '2099-01-01T00:31:00.001Z',
|
||||
f => { f.returnToIdle.quietStartedAt = '2099-01-01T00:19:00Z'; f.returnToIdle.quietFinishedAt = '2099-01-01T00:20:30Z'; },
|
||||
f => f.returnToIdle.quietStartedAt = '2098-12-31T23:00:00Z',
|
||||
f => f.returnToIdle.quietFinishedAt = '2099-01-01T01:00:01Z',
|
||||
f => f.returnToIdle.quietFinishedAt = '2099-01-01T00:30:00Z',
|
||||
f => f.returnToIdle.observedAt = '2099-01-01T00:32:00Z',
|
||||
f => f.returnToIdle.freshQuietWindowMs = Infinity,
|
||||
f => f.returnToIdle.freshQuietWindowMs = 90_001,
|
||||
f => delete f.returnToIdle.quietStartedAt,
|
||||
]) {
|
||||
const f = woken(); change(f); assert.equal(evaluateSleepQualification(f, expected).layers.returnToIdle.verdict, 'NOT VERIFIED');
|
||||
}
|
||||
});
|
||||
test('exact observed active providers are negative evidence while unknown identity stays inconclusive', () => {
|
||||
for (const state of [{ currentState: 'active', pendingState: null }, { currentState: 'idle', pendingState: 'active' }]) {
|
||||
const f = deployed(); Object.assign(f.provider.neon, state);
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.databaseSuspension.verdict, 'NOT VERIFIED');
|
||||
f.provider.neon.identityVerified = false;
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.databaseSuspension.verdict, 'INCONCLUSIVE');
|
||||
}
|
||||
const f = deployed(); f.provider.railway.services[0].allDeploymentsStopped = false;
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.hostSuspension.verdict, 'NOT VERIFIED');
|
||||
f.provider.railway.services[0].identityVerified = false;
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.hostSuspension.verdict, 'INCONCLUSIVE');
|
||||
});
|
||||
|
||||
test('logical digest is stable across origins and detects changed enabled configuration', () => {
|
||||
assert.equal(sleepConfigurationDigest(configuration), configDigest);
|
||||
const f = fixture(); f.configuration.telemetryOrigin = 'http://127.0.0.1:5555/telemetry'; f.configuration.otelOrigin = 'http://127.0.0.1:5555';
|
||||
f.collectorOrigins = { telemetry: f.configuration.telemetryOrigin, otel: f.configuration.otelOrigin };
|
||||
assert.equal(evaluateSleepQualification(f, expected).layers.workerQuiet.verdict, 'VERIFIED');
|
||||
assert.throws(() => evaluateSleepQualification(f, { ...expected, collectorOrigins: fixture().collectorOrigins }), /collector_origins/);
|
||||
for (const change of [x => x.configuration.announcements = false, x => x.configuration.serveUi = false,
|
||||
x => x.configuration.automaticBackups.intervalMinutes = 5, x => x.configuration.automaticBackups.idleCheckpoint = false,
|
||||
x => x.configuration.managedInstance = true, x => x.configuration.deploymentMode = 'authenticated', x => x.configuration.storageProvider = 's3',
|
||||
x => x.configuration.otelOrigin = '', x => x.configuration.telemetryOrigin = '']) {
|
||||
const changed = fixture(); change(changed); assert.throws(() => evaluateSleepQualification(changed, expected), /configuration_digest/);
|
||||
}
|
||||
});
|
||||
test('configured backup feature requires observed verified owned archives and a wake marker', () => {
|
||||
for (const change of [f => delete f.backupCheckpoint, f => f.backupCheckpoint.archives = [],
|
||||
f => f.backupCheckpoint.wakeMarkerObserved = false, f => f.backupCheckpoint.failureObserved = true,
|
||||
f => f.backupCheckpoint.archives[0].gzipVerified = false, f => f.backupCheckpoint.archives[0].fixtureIdentifierFound = false,
|
||||
f => f.backupCheckpoint.archives[0].bytes = 0, f => f.backupCheckpoint.archives[0].sha256 = 'invalid',
|
||||
f => f.backupCheckpoint.archives[0].createdAt = '2098-01-01T00:00:00Z',
|
||||
f => f.backupCheckpoint.archives[0].createdAtSource = 'unknown',
|
||||
f => f.backupCheckpoint.startedAt = '2099-01-01T00:11:00Z']) {
|
||||
const f = fixture(); change(f); assert.equal(evaluateSleepQualification(f, expected).layers.workerQuiet.verdict, 'NOT VERIFIED');
|
||||
}
|
||||
});
|
||||
test('receipt finalization follows observations and validates the actual resulting receipt', () => {
|
||||
const f = fixture(); delete f.finishedAt;
|
||||
let tick = Date.parse(f.eligibility.observedAt);
|
||||
const { receipt, report } = finalizeLocalSleepReceipt(f, expected, () => new Date(++tick).toISOString());
|
||||
assert.ok(Date.parse(receipt.finishedAt) > Date.parse(receipt.eligibility.observedAt));
|
||||
assert.equal(report.layers.eligibility.verdict, 'VERIFIED');
|
||||
assert.throws(() => finalizeLocalSleepReceipt(f, expected, () => f.startedAt), /local_evidence/);
|
||||
});
|
||||
test('CLI exits one for known active providers and zero for inconclusive identity', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'sleep-evidence-cli-'));
|
||||
try {
|
||||
const input = path.join(directory, 'receipt.json'), expectedPath = path.join(directory, 'expected.json'), output = path.join(directory, 'report.json');
|
||||
await writeFile(expectedPath, JSON.stringify(expected));
|
||||
const f = deployed(); f.provider.neon.currentState = 'active';
|
||||
for (const [identityVerified, status] of [[true, 1], [false, 0]]) {
|
||||
f.provider.neon.identityVerified = identityVerified; await writeFile(input, JSON.stringify(f));
|
||||
const child = spawnSync(process.execPath, [fileURLToPath(new URL('../../scripts/sleep-qualification/evidence.mjs', import.meta.url)), input, expectedPath, output], { encoding: 'utf8' });
|
||||
assert.equal(child.status, status, child.stderr);
|
||||
}
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -290,6 +290,9 @@ import { createHeartbeatQueueTestDependencies } from "./helpers/heartbeat-queue.
|
||||
async function interruptedBootstrap() {
|
||||
const f = await seed(); await f.finish(); const run = await f.run();
|
||||
const interrupted = await terminalizeLegacyExecution({ db, run, status: "interrupted", patch: {
|
||||
// The real controller claim records preparation before terminal status
|
||||
// records completion. The delivery fixture otherwise leaves both unset.
|
||||
executionStage: "preparing", finishedAt: new Date(),
|
||||
errorCode: "server_shutdown_interrupted", runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner" } },
|
||||
} });
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.sourceId)).toMatchObject({ runId: run.id });
|
||||
@@ -338,8 +341,10 @@ import { createHeartbeatQueueTestDependencies } from "./helpers/heartbeat-queue.
|
||||
expect(await settleInterruptedNativeBootstrap(db, { run, providerDispatchStarted: false })).not.toBeNull();
|
||||
expect(await issueService(db).getById(f.sourceId)).toMatchObject({ status: "blocked" });
|
||||
});
|
||||
it.each(["provider-entered", "runtime-selected", "other-adapter", "restore-unsafe", "active-lease", "cleanup-failed", "retry-exhausted"])("retains an interrupted bootstrap hold for %s", async kind => {
|
||||
it.each(["provider-entered", "runtime-selected", "other-adapter", "restore-unsafe", "active-lease", "cleanup-failed", "retry-exhausted", "missing-stage", "unfinished"])("retains an interrupted bootstrap hold for %s", async kind => {
|
||||
const { f, run } = await interruptedBootstrap();
|
||||
if (kind === "missing-stage") await db.update(heartbeatRuns).set({ executionStage: null }).where(eq(heartbeatRuns.id, run.id));
|
||||
if (kind === "unfinished") await db.update(heartbeatRuns).set({ finishedAt: null }).where(eq(heartbeatRuns.id, run.id));
|
||||
if (kind === "runtime-selected") await db.update(heartbeatRuns).set({ runtimeModeResolvedAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
|
||||
if (kind === "other-adapter") await db.update(heartbeatRuns).set({ runnerProfileJson: { adapterDispatch: { adapterType: "process" } } }).where(eq(heartbeatRuns.id, run.id));
|
||||
if (kind === "restore-unsafe") await db.update(heartbeatRuns).set({ resultJson: { workspaceRestoreFailure: "restore_unsafe_archive" } }).where(eq(heartbeatRuns.id, run.id));
|
||||
|
||||
@@ -330,7 +330,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await db.transaction(async tx => {
|
||||
await createWakeAdmissionWriter().coalesceIntoActiveExecutionRun(
|
||||
createAdmissionTransactionScope(f.companyId, tx as unknown as typeof db), {
|
||||
companyId: f.companyId, activeExecutionRunId: seeded.id, mergedContextSnapshot: chatContextSnapshot(),
|
||||
companyId: f.companyId, activeExecutionRunId: seeded.id, activeExecutionRunStatus: seeded.status, mergedContextSnapshot: chatContextSnapshot(),
|
||||
agentId: f.agentId, source: "on_demand", triggerDetail: null, payload: { issueId: f.issueId },
|
||||
requestedByActorType: "user", requestedByActorId: f.userId, idempotencyKey: null,
|
||||
});
|
||||
|
||||
@@ -3808,17 +3808,23 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
} as unknown as PluginWorkerManager;
|
||||
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
|
||||
|
||||
await expect(runtimeWithPlugin.acquireRunLease({
|
||||
companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
agentId,
|
||||
heartbeatRunId: runId,
|
||||
persistedExecutionWorkspace: {
|
||||
id: executionWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
},
|
||||
})).rejects.toThrow("the lease was preserved and no replacement was created");
|
||||
// Keep the exact configured budget stable across deadline creation and RPC dispatch.
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(Date.now());
|
||||
try {
|
||||
await expect(runtimeWithPlugin.acquireRunLease({
|
||||
companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
agentId,
|
||||
heartbeatRunId: runId,
|
||||
persistedExecutionWorkspace: {
|
||||
id: executionWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
},
|
||||
})).rejects.toThrow("the lease was preserved and no replacement was created");
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
|
||||
expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentResumeLease", expect.objectContaining({
|
||||
driverKey: "fake-plugin",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { S3Client, CreateBucketCommand, DeleteBucketCommand, DeleteObjectsCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { activityLog, agents, agentFileRoots, agentInstructionWorkingCopies, companies, executionWorkspaces, executionWorkspaceSnapshots, issues, workspaceOperations, createDb, closeFileStorageDb, heartbeatRuns, providerTraceRecords } from "@paperclipai/db";
|
||||
import { activityLog, agentWakeupRequests, agents, agentFileRoots, agentInstructionWorkingCopies, companies, executionWorkspaces, executionWorkspaceSnapshots, issues, workspaceOperations, createDb, closeFileStorageDb, heartbeatRuns, providerTraceRecords } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { readRemoteWorkspaceAuthority } from "@paperclipai/shared";
|
||||
import { getWorkspaceSnapshot, loadWorkspaceSnapshotArchive } from "../services/remote-workspace-snapshot-storage.js";
|
||||
@@ -53,6 +53,7 @@ describe.skipIf(!process.env.PAPERCLIP_TEST_S3_ENDPOINT)("existing-volume enroll
|
||||
await db.delete(issues).where(eq(issues.companyId, companyId));
|
||||
await db.delete(executionWorkspaces).where(eq(executionWorkspaces.companyId, companyId));
|
||||
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
|
||||
await db.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId));
|
||||
await db.delete(agents).where(eq(agents.companyId, companyId));
|
||||
await db.delete(companies).where(eq(companies.id, companyId));
|
||||
let token: string | undefined;
|
||||
@@ -104,7 +105,8 @@ describe.skipIf(!process.env.PAPERCLIP_TEST_S3_ENDPOINT)("existing-volume enroll
|
||||
const parent = path.join(home, "project"), child = path.join(parent, ".paperclip/worktrees/task");
|
||||
await fs.mkdir(child, { recursive: true });
|
||||
const fixtures = [{ cwd: parent, file: "project.txt", bytes: "project work" }, { cwd: child, file: "task.txt", bytes: "nested task work" }];
|
||||
const ids: string[] = [];
|
||||
const ids: string[] = [], queued = [];
|
||||
const queuedAgent = await agent();
|
||||
for (const fixture of fixtures) {
|
||||
await fs.writeFile(path.join(fixture.cwd, fixture.file), fixture.bytes);
|
||||
const [issue] = await db.insert(issues).values({ companyId, title: fixture.file }).returning();
|
||||
@@ -112,10 +114,20 @@ describe.skipIf(!process.env.PAPERCLIP_TEST_S3_ENDPOINT)("existing-volume enroll
|
||||
name: fixture.file, mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd: fixture.cwd }).returning();
|
||||
ids.push(workspace!.id);
|
||||
await db.update(issues).set({ executionWorkspaceId: workspace!.id }).where(eq(issues.id, issue!.id));
|
||||
const [wake] = await db.insert(agentWakeupRequests).values({ companyId, agentId: queuedAgent.id,
|
||||
source: "on_demand", payload: { issueId: issue!.id, text: "Continue my saved work" } }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: queuedAgent.id,
|
||||
issueId: issue!.id, wakeupRequestId: wake!.id, status: "queued", contextSnapshot: { issueId: issue!.id,
|
||||
executionWorkspaceId: workspace!.id } }).returning();
|
||||
queued.push({ run: run!, wake: wake! });
|
||||
}
|
||||
expect(await migrate()).toMatchObject({ eligibility: { eligible: true }, counts: { workspaces: 2 } });
|
||||
await fs.rm(home, { recursive: true }); home = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-successor-"));
|
||||
vi.stubEnv("PAPERCLIP_HOME", home);
|
||||
for (const { run, wake } of queued) {
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run);
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id)))[0]).toEqual(wake);
|
||||
}
|
||||
for (const [index, workspaceId] of ids.entries()) {
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId));
|
||||
const authority = readRemoteWorkspaceAuthority(workspace!.metadata)!;
|
||||
@@ -137,6 +149,8 @@ describe.skipIf(!process.env.PAPERCLIP_TEST_S3_ENDPOINT)("existing-volume enroll
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: row.id, status: "interrupted",
|
||||
runtimeMode: "native", logStore: "local_file", logRef });
|
||||
vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", "");
|
||||
const [queued] = await db.insert(heartbeatRuns).values({ companyId, agentId: row.id, status: "queued",
|
||||
contextSnapshot: { input: "Resume this turn after maintenance" } }).returning();
|
||||
const original = resolveDecisionSigningSecret();
|
||||
const listener = createServer();
|
||||
await new Promise<void>(resolve => listener.listen(0, "127.0.0.1", resolve));
|
||||
@@ -167,6 +181,7 @@ describe.skipIf(!process.env.PAPERCLIP_TEST_S3_ENDPOINT)("existing-volume enroll
|
||||
}
|
||||
expect(status).toMatchObject({ id, commit: stamp.commit, state: "ready", eligibility: { eligible: true } });
|
||||
expect(JSON.stringify(status)).not.toContain(original);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, queued!.id)))[0]).toEqual(queued);
|
||||
const exported = await fetch(`${origin}/export`, { method: "POST", headers }).then(r => r.json());
|
||||
expect(exported).toMatchObject({ id, commit: stamp.commit, decisionSigningSecret: original });
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
|
||||
@@ -1445,7 +1445,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let another user take over an archived personal URL connection", async () => {
|
||||
it("creates a separate fresh personal URL connection and keeps explicit reconnect owner-bound", async () => {
|
||||
const fixture = installMcpOAuthFixture({ auth: "oauth" });
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
@@ -1456,18 +1456,36 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id, principalType: "user", principalId: "other-user", status: "active", membershipRole: "admin",
|
||||
});
|
||||
const archivedConnection = await db.select().from(toolConnections).where(eq(toolConnections.id, first.connectionId));
|
||||
const archivedApplication = await db.select().from(toolApplications).where(eq(toolApplications.id, first.application.id));
|
||||
fixture.fetchMock.mockClear();
|
||||
|
||||
await expect(service.connectGalleryApp(company.id, input, {
|
||||
actorType: "user", actorId: "other-user",
|
||||
})).rejects.toMatchObject({ status: 403, message: "Only the existing personal identity can reconnect this connection" });
|
||||
for (const target of [{ reconnectConnectionId: first.connectionId }, { applicationId: first.application.id }]) {
|
||||
await expect(service.connectGalleryApp(company.id, { ...input, ...target }, {
|
||||
actorType: "user", actorId: "other-user",
|
||||
})).rejects.toMatchObject({ status: 403, message: "Only the existing personal identity can reconnect this connection" });
|
||||
}
|
||||
|
||||
expect(fixture.fetchMock).not.toHaveBeenCalled();
|
||||
const fresh = await service.connectGalleryApp(company.id, input, { actorType: "user", actorId: "other-user" });
|
||||
expect(fresh.connectionId).not.toBe(first.connectionId);
|
||||
expect(fresh.application.id).not.toBe(first.application.id);
|
||||
expect(fresh.auth).toMatchObject({ kind: "oauth" });
|
||||
await expect(service.getConnection(fresh.connectionId)).resolves.toMatchObject({
|
||||
createdByUserId: "other-user", credentialPolicy: "per_user", credentialSecretRefs: [],
|
||||
});
|
||||
await expect(db.select().from(toolConnections).where(eq(toolConnections.id, first.connectionId)))
|
||||
.resolves.toEqual(archivedConnection);
|
||||
await expect(db.select().from(toolApplications).where(eq(toolApplications.id, first.application.id)))
|
||||
.resolves.toEqual(archivedApplication);
|
||||
// OAuth grants are created only after authorization; discovery is not a grant.
|
||||
await expect(db.select().from(connectionGrants)).resolves.toHaveLength(0);
|
||||
await expect(service.getConnection(first.connectionId)).resolves.toMatchObject({
|
||||
status: "archived", createdByUserId: "board-user",
|
||||
});
|
||||
const resumed = await service.connectGalleryApp(company.id, input, { actorType: "user", actorId: "board-user" });
|
||||
const resumed = await service.connectGalleryApp(company.id, {
|
||||
...input, reconnectConnectionId: first.connectionId,
|
||||
}, { actorType: "user", actorId: "board-user" });
|
||||
expect(resumed.connectionId).toBe(first.connectionId);
|
||||
expect(resumed.auth).toMatchObject({ kind: "oauth" });
|
||||
});
|
||||
@@ -1544,7 +1562,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
||||
return jsonResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: FIXTURE_TOOLS } });
|
||||
});
|
||||
const failure = service.connectGalleryApp(company.id, {
|
||||
link: MCP_URL, name: "Personal retry rollback", grantKind: "user",
|
||||
link: MCP_URL, name: "Personal retry rollback", grantKind: "user", reconnectConnectionId: first.connectionId,
|
||||
}, actor).then(() => null, (error: unknown) => error);
|
||||
await Promise.race([catalogPending, failure.then((error) => { throw error ?? new Error("Retry finished before the catalog probe"); })]);
|
||||
try {
|
||||
@@ -1587,7 +1605,9 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
||||
FOR EACH ROW EXECUTE FUNCTION test_personal_profile_failure()
|
||||
`);
|
||||
try {
|
||||
await expect(service.connectGalleryApp(company.id, input, actor)).rejects.toThrow();
|
||||
await expect(service.connectGalleryApp(company.id, {
|
||||
...input, reconnectConnectionId: first.connectionId,
|
||||
}, actor)).rejects.toThrow();
|
||||
await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, first.connectionId)))
|
||||
.resolves.toEqual(catalogBefore);
|
||||
await expect(db.select().from(toolProfiles).where(eq(toolProfiles.companyId, company.id))).resolves.toHaveLength(0);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { S3Client, CreateBucketCommand, DeleteBucketCommand, DeleteObjectsCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, closeRegisteredClients, environmentLeases,
|
||||
environments, executionWorkspaces, heartbeatRuns, agentWakeupRequests, issues, issueWorkProducts, nativeRunResults } from "@paperclipai/db";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
import type { NativeExecutionInput, NativeSession, NativeSessionBackend, PrpEvent } from "@paperclipai/paperclip-runner";
|
||||
import type { EnvironmentLease } from "@paperclipai/shared";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createIsolatedTestHome } from "./helpers/isolated-test-home.js";
|
||||
import { heartbeatService, startTaskDrain, stopTaskDrain } from "../services/heartbeat.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import * as environmentTarget from "../services/environment-execution-target.js";
|
||||
import { migrateEphemeralServerData } from "../maintenance/ephemeral-migration-data.js";
|
||||
import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
|
||||
import { materializeInstructionBytes } from "../services/agent-instruction-files.js";
|
||||
import { flushInFlightRunLogMirrors } from "../services/run-log-store.js";
|
||||
import { CONTROL_PLANE_CONFORMANCE_RESULT, CONTROL_PLANE_CONFORMANCE_TERMINAL } from "../vendor/paperclip-runner/testing.js";
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async importOriginal => ({
|
||||
...await importOriginal<typeof import("@paperclipai/adapter-utils/execution-target")>(),
|
||||
// The scripted session makes no sandbox-to-host API calls.
|
||||
startAdapterExecutionTargetPaperclipBridge: async () => null,
|
||||
}));
|
||||
|
||||
// Real database, migration, object storage, queue claim, workspace restoration,
|
||||
// instruction materialization and result settlement. Only the sandbox transport
|
||||
// and provider session are scripted; neither can read the deleted predecessor.
|
||||
describe.skipIf(!process.env.PAPERCLIP_TEST_S3_ENDPOINT)("queued turn after preserving offline enrollment", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>, root: string, bucket: string, s3: S3Client;
|
||||
let home: ReturnType<typeof createIsolatedTestHome>;
|
||||
let replacement: ReturnType<typeof heartbeatService> | undefined;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("queued-enrollment-");
|
||||
db = createDb(database.connectionString);
|
||||
home = createIsolatedTestHome(); root = await fs.realpath(home.home);
|
||||
bucket = `queued-enrollment-${randomUUID()}`;
|
||||
s3 = new S3Client({ endpoint: process.env.PAPERCLIP_TEST_S3_ENDPOINT, region: "us-east-1", forcePathStyle: true });
|
||||
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
vi.stubEnv("DATABASE_URL", database.connectionString);
|
||||
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "predecessor"));
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "74".repeat(32));
|
||||
vi.stubEnv("PAPERCLIP_DURABLE_AGENT_FILES", "true");
|
||||
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
|
||||
vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", "false");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "s3");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", bucket);
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_REGION", "us-east-1");
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_ENDPOINT", process.env.PAPERCLIP_TEST_S3_ENDPOINT!);
|
||||
vi.stubEnv("PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", "true");
|
||||
}, 90_000);
|
||||
afterAll(async () => {
|
||||
stopTaskDrain();
|
||||
await replacement?.drainActiveRunExecutions();
|
||||
await flushInFlightRunLogMirrors();
|
||||
vi.restoreAllMocks();
|
||||
if (database) await closeRegisteredClients(database.connectionString, { scope: "database" });
|
||||
await database?.cleanup();
|
||||
if (s3) {
|
||||
for (;;) {
|
||||
const page = await s3.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
if (!page.Contents?.length) break;
|
||||
await s3.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: page.Contents.map(row => ({ Key: row.Key! })) } }));
|
||||
}
|
||||
await s3.send(new DeleteBucketCommand({ Bucket: bucket })); s3.destroy();
|
||||
}
|
||||
home?.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("admits the original queued run once and reads its saved workspace and instructions without the old disk", async () => {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
|
||||
const workProductId = randomUUID(), evidenceRef = `work_product:${workProductId}`;
|
||||
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: true, enableIsolatedWorkspaces: true });
|
||||
await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companies).values({ id: companyId, name: "Queued enrollment", issuePrefix: "QEM", defaultResponsibleUserId: userId });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
||||
const [environment] = await db.insert(environments).values({ name: `Scripted sandbox ${companyId}`, driver: "sandbox",
|
||||
status: "active", config: { provider: "daytona" } }).returning();
|
||||
const instructions = "Preserved instructions: read saved.txt before completing this turn.";
|
||||
const instructionsRoot = resolveManagedInstructionsRoot({ id: agentId, companyId, name: "Queued worker", adapterConfig: {} });
|
||||
await materializeInstructionBytes(instructionsRoot, "AGENTS.md", Buffer.from(instructions));
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Queued worker", adapterType: "paperclip_runner", status: "idle",
|
||||
defaultEnvironmentId: environment.id, adapterConfig: { provider: "codex", instructionsBundleMode: "managed", instructionsRootPath: instructionsRoot },
|
||||
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Continue saved work", issueNumber: 1, identifier: "QEM-1",
|
||||
status: "todo", assigneeAgentId: agentId, responsibleUserId: userId, executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace" } });
|
||||
const oldHome = process.env.PAPERCLIP_HOME!, cwd = path.join(oldHome, "task");
|
||||
await fs.mkdir(cwd, { recursive: true }); await fs.writeFile(path.join(cwd, "saved.txt"), "unfinished work before migration");
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, sourceIssueId: issueId, name: "Saved task",
|
||||
mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd }).returning();
|
||||
await db.update(issues).set({ executionWorkspaceId: workspace.id }).where(eq(issues.id, issueId));
|
||||
await db.insert(issueWorkProducts).values({ id: workProductId, companyId, issueId, type: "artifact", provider: "paperclip",
|
||||
title: "Migration fixture evidence", status: "ready_for_review", reviewState: "approved" });
|
||||
const predecessor = heartbeatService(db);
|
||||
startTaskDrain({});
|
||||
await predecessor.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", reason: "manual",
|
||||
requestedByActorType: "user", requestedByActorId: userId, payload: { issueId }, contextSnapshot: { issueId, skipIssueComment: true } });
|
||||
await predecessor.drainActiveRunExecutions();
|
||||
const [queued] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
|
||||
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId));
|
||||
expect(queued).toMatchObject({ status: "queued", startedAt: null, wakeupRequestId: wake.id });
|
||||
expect(wake.status).toBe("queued");
|
||||
expect(await migrateEphemeralServerData(db, { signal: AbortSignal.timeout(60_000), phase: () => {} }))
|
||||
.toMatchObject({ eligibility: { eligible: true }, counts: { workspaces: 1 } });
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, queued.id)))[0]).toEqual(queued);
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id)))[0]).toEqual(wake);
|
||||
await fs.rm(oldHome, { recursive: true });
|
||||
await expect(fs.stat(oldHome)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "replacement"));
|
||||
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "false");
|
||||
|
||||
const remoteCwd = path.join(root, "independent-sandbox"); await fs.mkdir(remoteCwd);
|
||||
const exec = promisify(execFile);
|
||||
const transfer: NonNullable<CommandManagedRuntimeRunner["syncIn"]> = async operations => {
|
||||
const results = [];
|
||||
for (const operation of operations) {
|
||||
for (const file of operation.files) {
|
||||
await fs.mkdir(path.dirname(file.targetPath), { recursive: true });
|
||||
await fs.cp(file.sourcePath, file.targetPath, { recursive: file.kind === "directory" });
|
||||
}
|
||||
results.push({ operationId: operation.operationId, filesTransferred: operation.files.length, bytesTransferred: 0 });
|
||||
}
|
||||
return { operations: results };
|
||||
};
|
||||
const runner: CommandManagedRuntimeRunner = { syncIn: transfer, syncOut: transfer, execute: async input => {
|
||||
const command = exec(input.command, input.args ?? [], { cwd: input.cwd ?? remoteCwd, env: { ...process.env, ...input.env },
|
||||
timeout: input.timeoutMs, maxBuffer: 4 * 1024 * 1024 });
|
||||
command.child.stdin?.end(input.stdin ?? "");
|
||||
const result = await command;
|
||||
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr, signal: null, timedOut: false, pid: process.pid, startedAt: new Date().toISOString() };
|
||||
} };
|
||||
vi.spyOn(environmentTarget, "resolveEnvironmentExecutionTarget").mockImplementation(async input => ({
|
||||
kind: "remote", transport: "sandbox", remoteCwd: typeof input.leaseMetadata?.remoteCwd === "string" ? input.leaseMetadata.remoteCwd : remoteCwd,
|
||||
runner, provider: "daytona", leaseId: input.leaseId, sandboxId: `fixture-${queued.id}`, timeoutMs: 60_000,
|
||||
}) as AdapterExecutionTarget);
|
||||
const runtime = environmentRuntimeService(db);
|
||||
vi.spyOn(runtime, "acquireRunLease").mockImplementation(async input => {
|
||||
const [lease] = await db.insert(environmentLeases).values({ companyId, environmentId: environment.id, heartbeatRunId: input.heartbeatRunId,
|
||||
issueId, executionWorkspaceId: workspace.id, provider: "daytona", providerLeaseId: `fixture-${queued.id}`, status: "active", metadata: { remoteCwd } }).returning();
|
||||
return { environment: input.environment, lease: lease as EnvironmentLease,
|
||||
leaseContext: { executionWorkspaceId: workspace.id, executionWorkspaceMode: "isolated_workspace" } };
|
||||
});
|
||||
// The scripted provider owns no external allocation. Retain its directory
|
||||
// until after the normal native workspace finalizer has completed.
|
||||
vi.spyOn(runtime, "releaseRunLeases").mockResolvedValue([]);
|
||||
const startTurn = vi.fn();
|
||||
const backendFactory = (execution: NativeExecutionInput): NativeSessionBackend => {
|
||||
const turnId = `turn-${queued.id}`;
|
||||
const identity = { companyId, agentId, issueId, runId: queued.id, sessionId: execution.session.normalizedSessionId! };
|
||||
const result = structuredClone(CONTROL_PLANE_CONFORMANCE_RESULT);
|
||||
result.completionClaim.contractRevision = execution.completionContract.contract.revision;
|
||||
result.completionClaim.criteria = execution.completionContract.contract.criteria.map(criterion => ({
|
||||
...result.completionClaim.criteria[0]!, criterionId: criterion.id, evidenceRefs: [evidenceRef],
|
||||
}));
|
||||
result.evidence = [{ kind: "work_product", ref: evidenceRef }]; result.verification[0]!.artifactRef = evidenceRef;
|
||||
let started!: () => void;
|
||||
const turnStarted = new Promise<void>(resolve => { started = resolve; });
|
||||
const session: NativeSession = {
|
||||
identity: () => identity,
|
||||
async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; },
|
||||
async *events() {
|
||||
await turnStarted;
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, queued.id));
|
||||
yield { schema: "paperclip.prp.event.v1", sourceEventId: `${queued.id}:terminal`, sourceSeq: 1, sourceInstanceId: run.runnerInstanceId!,
|
||||
sourceKind: "runner", runId: queued.id, normalizedSessionId: identity.sessionId, turnId, eventType: "turn.completed",
|
||||
schemaVersion: 1, priority: 0, emittedAt: new Date().toISOString(), payload: {} } satisfies PrpEvent;
|
||||
},
|
||||
async startTurn(input) {
|
||||
expect(execution.binding.runId).toBe(queued.id);
|
||||
expect(execution.workspace.cwd).toBeTruthy();
|
||||
expect(await fs.readFile(path.join(execution.workspace.cwd!, "saved.txt"), "utf8")).toBe("unfinished work before migration");
|
||||
if (!("runtimeContext" in execution)) throw new Error("Expected native runtime context");
|
||||
const bundle = execution.runtimeContext.instructions;
|
||||
const instructionPath = path.join(bundle.bundle.rootPath, bundle.entryPath);
|
||||
expect(instructionPath.startsWith(`${oldHome}/`)).toBe(false);
|
||||
expect(await fs.readFile(instructionPath, "utf8")).toBe(instructions);
|
||||
await expect(fs.stat(path.join(cwd, "saved.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
startTurn(input); started(); return { turnId };
|
||||
},
|
||||
async result() { await turnStarted; return { result, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, turnId }; },
|
||||
async snapshot() { return { backendKind: "mock", sessionId: "queued-migration", identity, providerSessionId: "queued-migration",
|
||||
activeTurnId: null, pendingRuntimeRequests: [], lineage: [], cursor: "1" }; },
|
||||
async close() {},
|
||||
};
|
||||
return { async descriptor() { return { kind: "mock", name: "queued-migration", version: "1",
|
||||
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true },
|
||||
runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } }; },
|
||||
async openSession() { return session; } };
|
||||
};
|
||||
replacement = heartbeatService(db, { environmentRuntime: runtime, nativeSessionBackendFactory: backendFactory });
|
||||
stopTaskDrain();
|
||||
await replacement.resumeQueuedRunsAfterDrain(); await replacement.drainActiveRunExecutions();
|
||||
const finished = await replacement.getRun(queued.id);
|
||||
expect(finished?.status, JSON.stringify({ errorCode: finished?.errorCode, error: finished?.error, result: finished?.resultJson })).toBe("succeeded");
|
||||
expect(finished?.wakeupRequestId).toBe(wake.id);
|
||||
await replacement.resumeQueuedRunsAfterDrain(); await replacement.drainActiveRunExecutions();
|
||||
expect(startTurn).toHaveBeenCalledOnce();
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(1);
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, queued.id))).toHaveLength(1);
|
||||
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId)))
|
||||
.toEqual([expect.objectContaining({ id: wake.id, status: "completed", runId: queued.id,
|
||||
claimedAt: expect.any(Date), finishedAt: expect.any(Date), payload: wake.payload })]);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mock } from 'node:test';
|
||||
import { DEFAULT_ANNOUNCEMENT_FEED_URL, announcementManifestSchema } from '@paperclipai/shared';
|
||||
|
||||
/** Test-child transport seam: actual route/feed/cache, no DNS or HTTPS claim. */
|
||||
export async function installMaintenanceAnnouncementBoundary() {
|
||||
const actual = await import('../../../app.js');
|
||||
const requests: string[] = [], unmatched: string[] = [];
|
||||
let intercepted = false;
|
||||
const manifest = announcementManifestSchema.parse({ schemaVersion: 1, announcement: {
|
||||
id: 'new-projects', eyebrow: 'New', title: 'Projects', description: 'Organize your work.',
|
||||
primaryAction: { kind: 'route', label: 'Open', path: '/projects' },
|
||||
} });
|
||||
const wrapped: typeof actual.createApp = (db, options) => {
|
||||
const announcements = options.announcements;
|
||||
if (!announcements?.enabled || announcements.feedUrl !== DEFAULT_ANNOUNCEMENT_FEED_URL) {
|
||||
throw new Error('Configured announcement boundary requires the exact enabled default feed');
|
||||
}
|
||||
intercepted = true;
|
||||
const fetch = async (url: URL, init: RequestInit) => {
|
||||
if (url.href !== DEFAULT_ANNOUNCEMENT_FEED_URL || init.method !== 'GET') {
|
||||
unmatched.push(url.href);
|
||||
throw new Error('Unmatched announcement transport is forbidden');
|
||||
}
|
||||
requests.push(url.href);
|
||||
return Response.json(manifest, { headers: { ETag: '"qualification-v1"' } });
|
||||
};
|
||||
const withTransport = { ...options, announcements: { ...announcements, fetch } };
|
||||
return actual.createApp(db, withTransport);
|
||||
};
|
||||
mock.module(new URL('../../../app.ts', import.meta.url).href, { namedExports: { ...actual, createApp: wrapped } });
|
||||
// Fail before server startup if the TS loader/module cache bypasses the mock.
|
||||
if ((await import('../../../app.js')).createApp !== wrapped) throw new Error('Announcement module boundary did not intercept the startup import');
|
||||
return { assertIntercepted() { if (!intercepted) throw new Error('Actual server bypassed the announcement boundary'); },
|
||||
snapshot: () => ({ intercepted, requests: [...requests], unmatched: [...unmatched],
|
||||
boundary: 'Feed transport injected through existing fetch seam; actual route/feed validation/cache/UI. DNS and HTTPS guard are not exercised in this combined case.' }) };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Transform, Writable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { createGunzip } from 'node:zlib';
|
||||
|
||||
/** Inspect only archives created by the normal idle owner after the quiet counters freeze.
|
||||
* Stream through EOF to validate the gzip trailer; retain no SQL/credential contents. */
|
||||
export async function observeIdleBackupCheckpoint(directory: string, beforeNames: string[], startedAt: string, fixtureId: string) {
|
||||
if (!/^[a-f0-9-]{36}$/.test(fixtureId)) throw new Error('Invalid owned backup fixture identifier');
|
||||
const checkpointMs = Date.parse(startedAt);
|
||||
if (!Number.isFinite(checkpointMs) || checkpointMs <= 0) throw new Error('Invalid owned checkpoint timestamp');
|
||||
const backupDir = path.join(directory, 'backups');
|
||||
const marker = await lstat(path.join(backupDir, '.idle-backup-wake-required'));
|
||||
if (!marker.isFile() || marker.isSymbolicLink() || marker.size === 0) throw new Error('Missing normal idle backup wake marker');
|
||||
for (const failure of [path.join(directory, 'health/db-backup-to-s3.failure'), path.join(directory, 'db-backup-to-s3.failure'), path.join(backupDir, 'db-backup-to-s3.failure')]) {
|
||||
try { await lstat(failure); throw new Error('Observed database backup failure marker'); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
}
|
||||
const names = (await readdir(backupDir)).filter(name => name.endsWith('.sql.gz') && !beforeNames.includes(name)).sort();
|
||||
if (!names.length) throw new Error('Normal idle owner produced no new archive');
|
||||
const archives = [];
|
||||
for (const name of names) {
|
||||
const handle = await open(path.join(backupDir, name), constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
// Some filesystems report birthtime=0. The new-name filter and opened
|
||||
// non-symlink file still apply; use its content mtime only in that case.
|
||||
// An old positive birthtime must not be rescued by a recent modification.
|
||||
const createdAtSource = stat.birthtimeMs === 0 ? 'mtime' : 'birthtime';
|
||||
const createdMs = stat.birthtimeMs === 0 ? stat.mtimeMs : stat.birthtimeMs;
|
||||
if (!stat.isFile() || stat.size <= 0 || !Number.isFinite(createdMs) || createdMs <= 0
|
||||
|| createdMs < checkpointMs || Math.trunc(createdMs) > Date.now()) throw new Error('Archive predates owned checkpoint or is invalid');
|
||||
const hash = createHash('sha256'); let sqlBytes = 0, tail = '', fixtureIdentifierFound = false;
|
||||
await pipeline(handle.createReadStream({ autoClose: false }),
|
||||
new Transform({ transform(chunk, _encoding, done) { hash.update(chunk); done(null, chunk); } }), createGunzip(),
|
||||
new Writable({ write(chunk, _encoding, done) {
|
||||
sqlBytes += chunk.length;
|
||||
if (sqlBytes > 512 * 1024 * 1024) { done(new Error('Owned fixture archive exceeded bounded observation size')); return; }
|
||||
const text = tail + chunk.toString('utf8'); fixtureIdentifierFound ||= text.includes(fixtureId);
|
||||
tail = text.slice(-fixtureId.length); done();
|
||||
} }));
|
||||
if (!sqlBytes || !fixtureIdentifierFound) throw new Error('Archive does not contain the owned fixture');
|
||||
archives.push({ name, bytes: stat.size, sqlBytes, sha256: hash.digest('hex'), createdAt: new Date(createdMs).toISOString(), createdAtSource, gzipVerified: true, fixtureIdentifierFound });
|
||||
} finally { await handle.close(); }
|
||||
}
|
||||
return { owner: 'normal-idle-checkpoint', startedAt, observedAt: new Date().toISOString(), archives, wakeMarkerObserved: true, failureObserved: false };
|
||||
}
|
||||
export async function idleBackupArchiveNames(directory: string) {
|
||||
try { return (await readdir(path.join(directory, 'backups'))).filter(name => name.endsWith('.sql.gz')); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { withNativeMaintenanceBrowser } from './native-browser.js';
|
||||
|
||||
/** Actual Chromium visibility; never substitutes a fake document or frozen lifecycle. */
|
||||
export async function observeMaintenanceBrowser(origin: string, announcementDescription?: string, tenantHeaders?: Record<string, string>) {
|
||||
return withNativeMaintenanceBrowser(async (browser, context) => {
|
||||
const unmatchedOrigins: string[] = [];
|
||||
if (tenantHeaders) {
|
||||
// Both HTTP and native WS need the simulated Cloud front door identity.
|
||||
// Gate every possible foreign transport BEFORE networking; no fixture
|
||||
// header can reach another origin. about:blank does not use networking.
|
||||
await context.route('**/*', async route => {
|
||||
if (new URL(route.request().url()).origin !== origin) { unmatchedOrigins.push(route.request().url()); await route.abort('blockedbyclient'); return; }
|
||||
await route.continue();
|
||||
});
|
||||
await context.routeWebSocket(url => url.origin.replace(/^ws/, 'http') !== origin, route => {
|
||||
unmatchedOrigins.push(route.url()); route.close({ code: 1008, reason: 'Outside owned app origin' });
|
||||
});
|
||||
await context.setExtraHTTPHeaders(tenantHeaders);
|
||||
}
|
||||
const page = await context.newPage();
|
||||
const requests: Array<{ at: number; path: string }> = [];
|
||||
page.on('request', request => { const url = new URL(request.url()); if (url.origin === origin) requests.push({ at: performance.now(), path: url.pathname }); });
|
||||
await page.goto(origin, { waitUntil: 'networkidle' });
|
||||
if (announcementDescription) await page.getByText(announcementDescription, { exact: true }).waitFor({ state: 'visible' });
|
||||
await page.bringToFront();
|
||||
if (await page.evaluate(() => document.visibilityState) !== 'visible') throw new Error('Browser active-use observation requires a visible page');
|
||||
const beforeActive = requests.length;
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
if (!requests.slice(beforeActive).some(request => request.path.startsWith('/api/'))) throw new Error('Active browser did not make ordinary app requests');
|
||||
const visibilityEvents: Array<{ state: string; rendererAt: number }> = [];
|
||||
const prefix = 'paperclip-sleep-visibility:';
|
||||
page.on('console', message => { if (message.text().startsWith(prefix)) visibilityEvents.push(JSON.parse(message.text().slice(prefix.length))); });
|
||||
// Passive native events only; never replace document visibility properties.
|
||||
await page.evaluate(prefix => document.addEventListener('visibilitychange', () => console.log(prefix + JSON.stringify({ state: document.visibilityState, rendererAt: performance.now() }))), prefix);
|
||||
const visibility = () => page.evaluate(() => ({ state: document.visibilityState, focused: document.hasFocus(), rendererAt: performance.now(),
|
||||
ownVisibilityOverride: Boolean(Object.getOwnPropertyDescriptor(document, 'visibilityState')), ownHiddenOverride: Boolean(Object.getOwnPropertyDescriptor(document, 'hidden')),
|
||||
ownFocusOverride: Boolean(Object.getOwnPropertyDescriptor(document, 'hasFocus')) }));
|
||||
const initialVisibility = await visibility();
|
||||
if (initialVisibility.state !== 'visible') throw new Error('Native browser is not visible before the actual tab switch');
|
||||
const foreground = await context.newPage();
|
||||
await foreground.goto('about:blank'); await foreground.bringToFront();
|
||||
const native = await browser.newBrowserCDPSession();
|
||||
const windowIdentity = async (target: typeof page) => {
|
||||
const session = await context.newCDPSession(target);
|
||||
try {
|
||||
const { targetInfo } = await session.send('Target.getTargetInfo');
|
||||
const { windowId } = await native.send('Browser.getWindowForTarget', { targetId: targetInfo.targetId });
|
||||
return { targetId: targetInfo.targetId, windowId };
|
||||
} finally { await session.detach(); }
|
||||
};
|
||||
const appWindow = await windowIdentity(page), foregroundWindow = await windowIdentity(foreground);
|
||||
if (appWindow.windowId !== foregroundWindow.windowId) throw new Error('Background browser qualification requires an actual same-window tab switch');
|
||||
const hiddenDeadline = performance.now() + 5_000;
|
||||
while (await page.evaluate(() => document.visibilityState) !== 'hidden') {
|
||||
if (performance.now() > hiddenDeadline) throw new Error('Browser could not establish a real background tab; visibility simulation is not qualification');
|
||||
await delay(50);
|
||||
}
|
||||
// Let requests already admitted by the transition settle before the untouched window.
|
||||
await delay(2_000);
|
||||
const initialHidden = await visibility();
|
||||
if (initialHidden.state !== 'hidden') throw new Error('Browser lost native hidden state during settling');
|
||||
if (!visibilityEvents.some(event => event.state === 'hidden' && event.rendererAt <= initialHidden.rendererAt)) throw new Error('Native hidden event was not observed');
|
||||
const hiddenRequests = requests.length, started = performance.now();
|
||||
await delay(90_000);
|
||||
const finalHidden = await visibility();
|
||||
const hiddenRealElapsedMs = performance.now() - started;
|
||||
if (hiddenRealElapsedMs < 90_000) throw new Error('Native hidden observation was shorter than 90 real seconds');
|
||||
const intervalVisibilityEvents = visibilityEvents.filter(event => event.rendererAt >= initialHidden.rendererAt && event.rendererAt <= finalHidden.rendererAt);
|
||||
if (finalHidden.state !== 'hidden' || intervalVisibilityEvents.some(event => event.state !== 'hidden')) throw new Error(`Browser left native hidden state: ${JSON.stringify({ initialHidden, finalHidden, intervalVisibilityEvents })}`);
|
||||
if ([initialVisibility, initialHidden, finalHidden].some(state => state.ownVisibilityOverride || state.ownHiddenOverride || state.ownFocusOverride)) throw new Error('Browser visibility or focus properties were overridden');
|
||||
const hidden = requests.slice(hiddenRequests);
|
||||
if (hidden.some(request => request.path.startsWith('/api/'))) throw new Error(`Background tab produced tenant traffic: ${JSON.stringify(hidden)}`);
|
||||
if (unmatchedOrigins.length) throw new Error(`Browser attempted non-owned transport: ${JSON.stringify(unmatchedOrigins)}`);
|
||||
await page.bringToFront(); const restoredVisibility = await visibility();
|
||||
if (restoredVisibility.state !== 'visible') throw new Error('Native app tab did not become visible after activation');
|
||||
if (!visibilityEvents.some(event => event.state === 'visible' && event.rendererAt >= finalHidden.rendererAt)) throw new Error('Native visible restoration event was not observed');
|
||||
return { activeRequests: requests.slice(beforeActive, hiddenRequests).length, hiddenRealElapsedMs,
|
||||
hiddenRequests: hidden, unmatchedOrigins, visibility: 'Native Chromium default context with no Playwright focus override', initialVisibility, initialHidden, finalHidden,
|
||||
intervalVisibilityEvents, visibilityEvents, restoredVisibility, appWindow, foregroundWindow, announcementRendered: Boolean(announcementDescription), authBoundary: tenantHeaders ? 'Owned Cloud front-door headers; actual tenant resolver/membership' : 'Legacy local board', closed: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Db } from '@paperclipai/db';
|
||||
import { githubIngressFixture } from '../github-webhook-ingress.js';
|
||||
import { chatChannelService } from '../../../services/chat-channels.js';
|
||||
|
||||
/** The fixture supplies the named external signed-webhook boundary; domain owners write receipts. */
|
||||
export async function produceChatHistory(db: Db) {
|
||||
const fixture = await githubIngressFixture(db);
|
||||
const action = await fixture.insertAction();
|
||||
await fixture.service.processGitHubWebhookIngress(action.id);
|
||||
const finished = await fixture.readAction(action.id);
|
||||
assert.equal(fixture.calls.length, 1);
|
||||
assert.equal(finished.status, 'processed');
|
||||
const service = chatChannelService(db, { heartbeat: { wakeup: async () => { throw new Error('Unexpected chat turn'); } },
|
||||
fetch: async () => { throw new Error('Unexpected provider HTTP outside signed-webhook fixture'); } });
|
||||
try {
|
||||
await service.configure(fixture.endpoint.id, { action: 'remove' }, 'local-board');
|
||||
} finally { await service.shutdown(); }
|
||||
return { companyId: fixture.companyId, actionId: action.id, endpointId: fixture.endpoint.id,
|
||||
boundary: 'Existing signed webhook fixture input; actual chat ingress completion and ordinary endpoint removal preserve history' };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { createDb } from '@paperclipai/db';
|
||||
import { expect } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { startMaintenanceServer } from './server-harness.js';
|
||||
|
||||
/** Real routes own request creation, human rejection, run cancellation and outbox retirement. */
|
||||
export async function produceCompletedToolReview(server: Awaited<ReturnType<typeof startMaintenanceServer>>, db: ReturnType<typeof createDb>, companyId: string) {
|
||||
await server.api('POST', `/companies/${companyId}/tools/policies`, { name: 'Review harmless qualification writes', policyType: 'require_approval', selectors: { toolName: 'mcp-remote-fixture:update_note' } });
|
||||
const readyPath = path.join(server.directory, `tool-review-ready-${randomUUID()}`);
|
||||
const readyProgram = `const fs=require('node:fs');const marker=${JSON.stringify(readyPath)};fs.writeFileSync(marker+'.pending',process.env.PAPERCLIP_RUN_ID,{flag:'wx'});fs.renameSync(marker+'.pending',marker);setTimeout(()=>process.exit(0),60000)`;
|
||||
const agent = await server.api<{ id: string }>('POST', `/companies/${companyId}/agents`, { name: 'Owned tool review process', role: 'general', adapterType: 'process',
|
||||
adapterConfig: { command: process.execPath, args: ['-e', readyProgram], cwd: server.directory }, runtimeConfig: { heartbeat: { enabled: true, intervalSec: 0, wakeOnDemand: true } } });
|
||||
const issue = await server.api<{ id: string }>('POST', `/companies/${companyId}/issues`, { title: 'Harmless tool review history', status: 'todo', assigneeAgentId: agent.id });
|
||||
let runId: string | undefined;
|
||||
await expect.poll(async () => {
|
||||
const [run] = await db.$client`select id,status from heartbeat_runs where issue_id=${issue.id} order by created_at desc limit 1`;
|
||||
runId = run?.id;
|
||||
if (run?.status !== 'running') return false;
|
||||
// The process runs only after its awaited adapter.invoke write can commit
|
||||
// behind the final dispatch gate's run lock. Status alone precedes setup.
|
||||
try { return await readFile(readyPath, 'utf8') === run.id; }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; }
|
||||
}, { timeout: 30_000 }).toBe(true);
|
||||
const readiness = { runId, marker: path.basename(readyPath), observedAt: new Date().toISOString(), source: 'actual-process-run-id-marker' };
|
||||
await writeFile(path.join(server.directory, 'sleep-tool-review-readiness.json'), JSON.stringify(readiness, null, 2));
|
||||
const session = await server.api<{ sessionId: string; token: string }>('POST', '/tool-gateway/sessions', { companyId, agentId: agent.id, runId, issueId: issue.id });
|
||||
const call = await fetch(`http://127.0.0.1:${server.port}/api/tool-gateway/tools/call`, { method: 'POST', signal: AbortSignal.timeout(15_000),
|
||||
headers: { 'Content-Type': 'application/json', 'x-paperclip-tool-gateway-token': session.token },
|
||||
body: JSON.stringify({ tool: 'mcp-remote-fixture:update_note', parameters: { noteId: 'qualification', body: 'Harmless denied operation' } }) });
|
||||
expect(call.ok).toBe(false);
|
||||
expect(JSON.stringify(await call.json())).toContain('approval_required');
|
||||
const [request] = await db.$client`select id,status from tool_action_requests where issue_id=${issue.id}`;
|
||||
expect(request.status).toBe('pending');
|
||||
await server.api('POST', `/tool-gateway/action-requests/${request.id}/decline`, { companyId, reason: 'Qualification declines the harmless write' });
|
||||
await server.api('PATCH', `/issues/${issue.id}`, { status: 'done' });
|
||||
await expect.poll(async () => {
|
||||
const [run] = await db.$client`select status from heartbeat_runs where id=${runId!}`;
|
||||
return ['succeeded', 'failed', 'cancelled', 'timed_out'].includes(run?.status);
|
||||
}, { timeout: 75_000 }).toBe(true);
|
||||
await expect.poll(async () => {
|
||||
const [delivery] = await db.$client`select delivered_at is not null as delivered from tool_action_deliveries where action_request_id=${request.id}`;
|
||||
return delivery?.delivered;
|
||||
}, { timeout: 30_000 }).toBe(true);
|
||||
await server.api('POST', `/tool-gateway/sessions/${session.sessionId}/revoke`, { companyId });
|
||||
return { requestId: request.id, issueId: issue.id, runId, readiness, boundary: 'Actual request/rejection/session revocation and closed-task retirement; no provider write executed' };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertEarlySleepDiagnostics } from './early-sleep-diagnostics.js';
|
||||
import type { startMaintenanceServer } from './server-harness.js';
|
||||
|
||||
type Report = { ownerId: string; draining: boolean; quiescent: boolean; expiresAt: string;
|
||||
idleSleepSafety: { diagnostics?: { complete?: boolean; checks?: Array<{ reason: string; state: string }> } } };
|
||||
const transientStates = new Map([['inspection.generation', 'unknown'], ['local.tracked_work', 'present'], ['plugins.runtime_drain', 'not_checked']]);
|
||||
|
||||
export function classifyEarlyDiagnostic(report: Report, elapsedSinceApiMs: number): 'strict' | 'transient' {
|
||||
const checks = report.idleSleepSafety.diagnostics?.checks;
|
||||
if (!Array.isArray(checks)) throw new Error('Missing early diagnostic checks');
|
||||
// A real durable blocker fails now, even if unrelated inspection work is in flight.
|
||||
const expectedStates = new Map([['local.registered_probes', 'present'], ['local.inspection', 'present'], ['backup.checkpoint', 'not_checked']]);
|
||||
for (const check of checks) {
|
||||
if (check.state === (expectedStates.get(check.reason) ?? 'none')) continue;
|
||||
if (transientStates.get(check.reason) !== check.state) throw new Error(`Unexpected early sleep blocker: ${check.reason}=${check.state}`);
|
||||
}
|
||||
if (checks.some(check => transientStates.get(check.reason) === check.state)) return 'transient';
|
||||
assertEarlySleepDiagnostics(report, elapsedSinceApiMs);
|
||||
return 'strict';
|
||||
}
|
||||
|
||||
/** Bounded setup-only observation, never a quiet-window poll or an uncertainty exemption. */
|
||||
export async function stabilizeEarlyDiagnostics(server: Awaited<ReturnType<typeof startMaintenanceServer>>, options: {
|
||||
ownerId: string; apiSucceededAt: number; read: () => Promise<Report>; persist: (observations: unknown[]) => Promise<void>;
|
||||
}) {
|
||||
const observations: unknown[] = [];
|
||||
for (let round = 1; round <= 3; round++) {
|
||||
const before = await server.settled();
|
||||
observations.push({ round, kind: 'settled-before', at: new Date().toISOString(), snapshot: before }); await options.persist(observations);
|
||||
assert(before.managed?.apiActivityActive, 'Actual successful managed API key activity is not held');
|
||||
const reports: Report[] = []; let transient = false;
|
||||
for (const position of ['initial', 'final']) {
|
||||
const report = await options.read(); reports.push(report);
|
||||
observations.push({ round, kind: position, at: new Date().toISOString(), report }); await options.persist(observations);
|
||||
assert(report.ownerId === options.ownerId && report.draining && report.quiescent && Date.parse(report.expiresAt) > Date.now(), 'Early diagnostic lost exact live hold');
|
||||
const classification = classifyEarlyDiagnostic(report, performance.now() - options.apiSucceededAt);
|
||||
transient = classification === 'transient' || transient;
|
||||
}
|
||||
const after = await server.snapshot();
|
||||
observations.push({ round, kind: 'snapshot-after', at: new Date().toISOString(), snapshot: after }); await options.persist(observations);
|
||||
if (transient) continue;
|
||||
assert(before.idle.active === 0 && after.idle.active === 0, 'Accepted early diagnostic has active owned work');
|
||||
// Each real HTTP authentication is tracked work. Its generation changes are
|
||||
// observer stimuli; each report separately proves its own scan stayed stable.
|
||||
assert.deepEqual(reports[0]!.idleSleepSafety, reports[1]!.idleSleepSafety, 'Accepted early diagnostic pair differs');
|
||||
assert(after.managed?.apiActivityActive, 'Real managed API activity expired during setup');
|
||||
return { rounds: round, initial: reports[0]!, final: reports[1]!, observations,
|
||||
boundary: 'Strict complete stable setup inventory with real API cooldown active; not sleep eligibility' };
|
||||
}
|
||||
throw new Error('Early inspection did not stabilize in three bounded owner-settled rounds');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
type Check = { reason: string; state: string };
|
||||
/** Early diagnostics are only a fail-fast blocker inventory, never sleep proof.
|
||||
* The registered-probe aggregate includes the real 10-minute API activity hold;
|
||||
* it cannot distinguish other probes, so the later full cooldown/proof is mandatory. */
|
||||
export function assertEarlySleepDiagnostics(report: { idleSleepSafety?: { diagnostics?: { complete?: boolean; checks?: Check[] } } }, elapsedSinceApiMs: number) {
|
||||
const diagnostics = report.idleSleepSafety?.diagnostics;
|
||||
if (!Number.isFinite(elapsedSinceApiMs) || elapsedSinceApiMs < 0 || elapsedSinceApiMs >= 600_000) throw new Error('Early diagnostic requires the actual API cooldown still active');
|
||||
if (diagnostics?.complete !== true || !Array.isArray(diagnostics.checks)) throw new Error('Early diagnostic coverage incomplete');
|
||||
const expected = new Map([['local.registered_probes', 'present'], ['local.inspection', 'present'], ['backup.checkpoint', 'not_checked']]);
|
||||
const seen = new Set<string>();
|
||||
for (const check of diagnostics.checks) {
|
||||
if (seen.has(check.reason) || check.state !== (expected.get(check.reason) ?? 'none')) throw new Error(`Unexpected early sleep blocker: ${check.reason}=${check.state}`);
|
||||
seen.add(check.reason);
|
||||
}
|
||||
for (const required of [...expected.keys(), 'inspection.hold', 'inspection.generation', 'durable.snapshot', 'deadlines.catalog']) {
|
||||
if (!seen.has(required)) throw new Error(`Missing early sleep diagnostic: ${required}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { toolApplications, toolConnections, connectionGrants, type Db } from '@paperclipai/db';
|
||||
import { writeConnectionCredential } from '../../../services/connection-credentials.js';
|
||||
import { secretService } from '../../../services/secrets.js';
|
||||
import { createOAuthCredentialStore } from '../../../services/tool-access/oauth/credential-store.js';
|
||||
import { createOAuthRefreshLeases } from '../../../services/tool-access/oauth/refresh-leases.js';
|
||||
import { createOAuthGrantRefresh } from '../../../services/tool-access/oauth/grant-refresh.js';
|
||||
import { createGitHubGrantContinuity } from '../../../services/tool-access/github-grant-continuity.js';
|
||||
import { createPaperclipCloudConnector, paperclipCloudConnectorConfigFromEnv } from '../../../services/paperclip-cloud-connector.js';
|
||||
|
||||
/** Bootstrap the named OAuth consent boundary, then use real vault, binding and continuity owners. */
|
||||
export async function prepareGitHubConfiguration(db: Db, companyId: string) {
|
||||
const owner = 'local-board', now = Date.now();
|
||||
const [application] = await db.insert(toolApplications).values({ companyId, applicationKey: randomUUID(), name: 'GitHub qualification', type: 'mcp_http', status: 'active' }).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId: application.id, uid: randomUUID(), name: 'Retained GitHub', status: 'active', enabled: false,
|
||||
transport: 'mcp_remote', authKind: 'oauth', credentialPolicy: 'per_user', credentialSource: 'paperclip_vault', transportConfig: {},
|
||||
config: { url: 'https://api.github.com/mcp', sourceTemplateKey: 'github', oauth: { strategy: 'paperclip_cloud_connector', connectorProfile: 'github.code' } } }).returning();
|
||||
const refs = [];
|
||||
for (const [configPath, value] of [['oauth.access_token', 'qualification-access'], ['oauth.refresh_token', 'qualification-refresh']] as const) {
|
||||
const saved = await writeConnectionCredential(db, { companyId, connectionName: 'GitHub', configPath, label: configPath, value, ownerUserId: owner });
|
||||
refs.push({ configPath, secretId: saved.secret.id, versionSelector: 'latest' as const, required: true });
|
||||
}
|
||||
const [grant] = await db.insert(connectionGrants).values({ companyId, connectionId: connection.id, kind: 'user', subjectUserId: owner, status: 'active', credentialSecretRefs: refs,
|
||||
providerTenant: { oauth: { strategy: 'paperclip_cloud_connector', accessTokenExpiresAt: new Date(now + 8 * 3_600_000).toISOString(), refreshedAt: new Date(now).toISOString() } } }).returning();
|
||||
await createOAuthCredentialStore(db, secretService(db)).syncCredentialBindings(connection, refs);
|
||||
return { connectionId: connection.id, grantId: grant.id };
|
||||
}
|
||||
|
||||
export async function settleGitHubConfiguration(db: Db, input: { connectionId: string; grantId: string }) {
|
||||
const getConnection = async () => (await db.select().from(toolConnections).where(eq(toolConnections.id, input.connectionId)))[0];
|
||||
const connection = await getConnection();
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, input.grantId));
|
||||
const config = paperclipCloudConnectorConfigFromEnv();
|
||||
if (!config) throw new Error('Owned qualification connector is not configured');
|
||||
const connector = createPaperclipCloudConnector({ config });
|
||||
const credentials = createOAuthCredentialStore(db, secretService(db));
|
||||
const leases = createOAuthRefreshLeases(db, () => new Date(), getConnection, credentials.getOAuthGrantRow);
|
||||
const refresh = createOAuthGrantRefresh(db, { now: () => new Date(), store: credentials, leases, getConnectionRow: getConnection,
|
||||
currentCloudConnector: () => connector, exchangeOAuthToken: async () => { throw new Error('Unexpected direct OAuth outside managed GitHub fixture'); } });
|
||||
const continuity = createGitHubGrantContinuity(db, { now: () => new Date(), currentCloudConnector: () => connector,
|
||||
refreshOAuthGrantCredentials: refresh, resolveOAuthGrantSecret: credentials.resolveOAuthGrantSecret });
|
||||
const settled = await continuity.refreshManagedGitHubGrantAccess(connection, grant, { actorType: 'system', actorId: 'qualification' });
|
||||
if (settled.providerTenant?.github?.bindingSync || settled.providerTenant?.github?.continuityRetry) throw new Error('Real GitHub binding debt did not settle');
|
||||
return { connectionId: connection.id, grantId: grant.id, credentialRefs: settled.credentialSecretRefs.map(ref => ref.configPath),
|
||||
boundary: 'Actual encrypted user credential/version/binding owners and GitHub authority refresh; real signed broker binding; named external GitHub HTTP only' };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { installMaintenanceIdleWorkCapture } from './idle-work-capture.js';
|
||||
|
||||
// Isolated Node/TS-loader identity probe. Never imports app/index or opens a DB.
|
||||
const original = await import('../../../services/task-admission.js');
|
||||
const before = process.argv[2] === 'unhandled' ? undefined : original.beginIdleTrackedWork();
|
||||
const capture = await installMaintenanceIdleWorkCapture();
|
||||
try {
|
||||
const observed = await import('../../../services/task-admission.js');
|
||||
assert.equal(observed.idleWorkSnapshot, original.idleWorkSnapshot);
|
||||
if (process.argv[2] === 'unhandled') {
|
||||
const reason = new Error('original unhandled tracked rejection');
|
||||
let listener!: (reason: unknown, promise: Promise<unknown>) => void;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const event = new Promise<{ reason: unknown; promise: Promise<unknown> }>((resolve, reject) => {
|
||||
listener = (reason, promise) => resolve({ reason, promise });
|
||||
process.once('unhandledRejection', listener);
|
||||
timer = setTimeout(() => reject(new Error('Original tracked rejection did not reach unhandledRejection')), 1500);
|
||||
});
|
||||
try {
|
||||
// Deliberately never await or attach a handler to this original promise.
|
||||
const tracked = observed.trackIdleWork(Promise.reject(reason));
|
||||
const unhandled = await event;
|
||||
assert.equal(unhandled.promise, tracked);
|
||||
assert.equal(unhandled.reason, reason);
|
||||
assert.equal(original.idleWorkSnapshot().active, 0);
|
||||
assert.equal(capture.snapshot().observedActive, 0);
|
||||
console.log(JSON.stringify({ unhandledOriginalPromise: true, unhandledOriginalReason: true, finalActive: 0 }));
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
process.removeListener('unhandledRejection', listener);
|
||||
}
|
||||
} else {
|
||||
assert.equal(observed.idleWorkSnapshot().active, 1); // Existing state was not reset.
|
||||
assert.equal(capture.snapshot().observedActive, 0); // The pre-install path is explicitly missing.
|
||||
before!();
|
||||
const finish = observed.beginIdleTrackedWork();
|
||||
assert.equal(original.idleWorkSnapshot().active, 1);
|
||||
finish(); finish();
|
||||
assert.equal(original.idleWorkSnapshot().active, 0);
|
||||
assert.equal(await observed.trackIdleWork(Promise.resolve('original-result')), 'original-result');
|
||||
assert.equal(original.idleWorkSnapshot().active, 0);
|
||||
const result = capture.summary();
|
||||
assert(result.snapshot.missingPaths > 0);
|
||||
console.log(JSON.stringify({ identity: true, originalStatePreserved: true, finalActive: original.idleWorkSnapshot().active, missingPathsExplicit: result.snapshot.missingPaths }));
|
||||
}
|
||||
} finally {
|
||||
before?.();
|
||||
capture.dispose();
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { mock } from 'node:test';
|
||||
import { promiseHooks } from 'node:v8';
|
||||
|
||||
type Admission = typeof import('../../../services/task-admission.js');
|
||||
type Entry = { id: number; kind: 'begin' | 'track'; sourceId: number; startedAt: number; generation: number };
|
||||
type Completion = Entry & { finishedAt: number; finishedGeneration: number };
|
||||
export type IdleWorkCaptureSnapshot = {
|
||||
actual: { active: number; generation: number }; observedActive: number; active: Entry[]; completionTail: Completion[];
|
||||
newSources: Array<{ id: number; callers: string[] }>; overflow: number; sourceOverflow: number;
|
||||
missingCallsites: number; missingPaths: number; observerErrors: number; disposed: boolean;
|
||||
};
|
||||
|
||||
/** Test-only metadata. No original work, promise, drain or counter is changed. */
|
||||
export function createMaintenanceIdleWorkCapture(actual: Pick<Admission, 'beginIdleTrackedWork' | 'trackIdleWork' | 'idleWorkSnapshot'>,
|
||||
controls: { now?: () => number; stack?: () => string | undefined } = {}) {
|
||||
const active = new Map<number, Entry>(), sources = new Map<string, { id: number; callers: string[] }>();
|
||||
const tail: Completion[] = [];
|
||||
let trackedPromises = new WeakMap<Promise<unknown>, number>();
|
||||
let disposed = false;
|
||||
let serial = 0, emittedSourceId = 0, overflow = 0, sourceOverflow = 0, missingCallsites = 0, missingPaths = 0, observerErrors = 0;
|
||||
const now = controls.now ?? Date.now;
|
||||
function start(kind: Entry['kind']): number | undefined {
|
||||
try {
|
||||
if (disposed) return;
|
||||
if (active.size >= 512) { overflow++; return; }
|
||||
const callers: string[] = [];
|
||||
for (const line of (controls.stack?.() ?? new Error().stack ?? '').split('\n')) {
|
||||
const match = line.match(/[/\\]server[/\\](?:src|dist)[/\\]((?:(?:services|middleware|routes|modules)[/\\][A-Za-z0-9_./\\-]+|(?:app|index))\.[cm]?[jt]s):(\d+):(\d+)/);
|
||||
if (!match || match[1]!.split(/[/\\]/).includes('..')) continue;
|
||||
const caller = `${match[1]!.replaceAll('\\', '/')}:${match[2]}:${match[3]}`;
|
||||
if (!callers.includes(caller)) callers.push(caller);
|
||||
if (callers.length === 4) break;
|
||||
}
|
||||
if (!callers.length) missingCallsites++;
|
||||
const key = JSON.stringify(callers); let source = sources.get(key);
|
||||
if (!source) {
|
||||
if (sources.size >= 512) { sourceOverflow++; return; }
|
||||
source = { id: sources.size + 1, callers }; sources.set(key, source);
|
||||
}
|
||||
const id = ++serial;
|
||||
active.set(id, { id, kind, sourceId: source.id, startedAt: now(), generation: actual.idleWorkSnapshot().generation });
|
||||
return id;
|
||||
} catch { observerErrors++; return; }
|
||||
}
|
||||
// V8 observes settlement without attaching a continuation or marking a rejected
|
||||
// original promise handled. Unrelated promises only perform a WeakMap lookup.
|
||||
const stopSettlementHook = promiseHooks.onSettled(promise => {
|
||||
try {
|
||||
const id = trackedPromises.get(promise);
|
||||
if (id === undefined) return;
|
||||
trackedPromises.delete(promise);
|
||||
finish(id);
|
||||
} catch { observerErrors++; }
|
||||
});
|
||||
function finish(id: number | undefined) {
|
||||
try {
|
||||
if (id === undefined) return;
|
||||
const entry = active.get(id); if (!entry) return;
|
||||
active.delete(id);
|
||||
tail.push({ ...entry, finishedAt: now(), finishedGeneration: actual.idleWorkSnapshot().generation });
|
||||
if (tail.length > 64) tail.shift();
|
||||
} catch { observerErrors++; }
|
||||
}
|
||||
const beginIdleTrackedWork: Admission['beginIdleTrackedWork'] = () => {
|
||||
const originalFinish = actual.beginIdleTrackedWork(); const id = start('begin');
|
||||
return () => { try { return originalFinish(); } finally { finish(id); } };
|
||||
};
|
||||
const trackIdleWork: Admission['trackIdleWork'] = <T>(work: Promise<T>) => {
|
||||
const original = actual.trackIdleWork(work); const id = start('track');
|
||||
try { if (id !== undefined) trackedPromises.set(original, id); } catch { observerErrors++; }
|
||||
return original;
|
||||
};
|
||||
return { beginIdleTrackedWork, trackIdleWork,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try { stopSettlementHook(); } finally { trackedPromises = new WeakMap(); }
|
||||
},
|
||||
snapshot(): IdleWorkCaptureSnapshot {
|
||||
const state = actual.idleWorkSnapshot();
|
||||
if (state.active !== active.size) missingPaths++;
|
||||
const newSources = [...sources.values()].filter(source => source.id > emittedSourceId).map(source => ({ ...source, callers: [...source.callers] }));
|
||||
emittedSourceId = sources.size;
|
||||
return { actual: state, observedActive: active.size, active: [...active.values()].map(entry => ({ ...entry })), completionTail: tail.map(entry => ({ ...entry })),
|
||||
newSources, overflow, sourceOverflow, missingCallsites, missingPaths, observerErrors, disposed };
|
||||
},
|
||||
summary() { return { sourceCatalog: [...sources.values()], snapshot: this.snapshot() }; },
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMaintenanceIdleWorkCapture() {
|
||||
const actual = await import('../../../services/task-admission.js');
|
||||
const capture = createMaintenanceIdleWorkCapture(actual);
|
||||
let moduleCapture: ReturnType<typeof mock.module> | undefined;
|
||||
const disposeCapture = capture.dispose;
|
||||
capture.dispose = () => { try { disposeCapture(); } finally { const installed = moduleCapture; moduleCapture = undefined; installed?.restore(); } };
|
||||
try {
|
||||
moduleCapture = mock.module(new URL('../../../services/task-admission.ts', import.meta.url).href, {
|
||||
namedExports: { ...actual, beginIdleTrackedWork: capture.beginIdleTrackedWork, trackIdleWork: capture.trackIdleWork },
|
||||
});
|
||||
const observed = await import('../../../services/task-admission.js');
|
||||
if (observed.beginIdleTrackedWork !== capture.beginIdleTrackedWork || observed.trackIdleWork !== capture.trackIdleWork
|
||||
|| Object.keys(observed).sort().join(',') !== Object.keys(actual).sort().join(',')
|
||||
|| Object.keys(actual).some(key => !['beginIdleTrackedWork', 'trackIdleWork'].includes(key)
|
||||
&& observed[key as keyof Admission] !== actual[key as keyof Admission])) throw new Error('Idle work module capture did not preserve original exports/identity');
|
||||
return capture;
|
||||
} catch (error) {
|
||||
try { capture.dispose(); } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Idle work capture installation and cleanup failed'); }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reuse existing settle-loop observations. No extra poll, timer, SQL or raw stack per sample. */
|
||||
export function createSettledWorkSamples(sourceCatalog: () => Array<{ id: number; callers: string[] }>) {
|
||||
// Finite longest setup observation at the existing 25ms cadence, plus its
|
||||
// terminal sample. Overflow still makes diagnostic completion fail closed.
|
||||
const sampleLimit = Math.ceil(61_000 / 25) + 1;
|
||||
const samples: Array<{ id: number; at: number; queryTotal: number; queryDelta: number; pending: number;
|
||||
idle: { active: number; generation: number }; activeIds: number[] }> = [];
|
||||
let previous: number | undefined, overflowSamples = 0, lastCapture: IdleWorkCaptureSnapshot | undefined;
|
||||
const startedAt = Date.now();
|
||||
return {
|
||||
sample(snapshot: { now: number; idle: { active: number; generation: number }; workCapture?: IdleWorkCaptureSnapshot }, queryTotal: number, pending: number) {
|
||||
lastCapture = snapshot.workCapture;
|
||||
if (samples.length < sampleLimit) samples.push({ id: samples.length + 1, at: snapshot.now, queryTotal,
|
||||
queryDelta: previous === undefined ? 0 : queryTotal - previous, pending, idle: { ...snapshot.idle }, activeIds: lastCapture?.active.map(entry => entry.id) ?? [] });
|
||||
else overflowSamples++;
|
||||
previous = queryTotal;
|
||||
},
|
||||
report() { return { startedAt, finishedAt: Date.now(), settleDeadlineMs: 30_000, sampleLimit, overflowSamples,
|
||||
samples, sourceCatalog: sourceCatalog(), lastCapture }; },
|
||||
assertComplete() {
|
||||
if (!lastCapture || overflowSamples || lastCapture.overflow || lastCapture.sourceOverflow || lastCapture.missingCallsites
|
||||
|| lastCapture.missingPaths || lastCapture.observerErrors || lastCapture.disposed) throw new Error('Passive idle work diagnostic capture incomplete');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Server } from "node:http";
|
||||
import type { IncomingHttpSnapshot } from "../../../../../scripts/sleep-qualification/evidence.mjs";
|
||||
|
||||
/** Passive test-child observation of the actual app listener, including direct clients. */
|
||||
export function observeMaintenanceIncomingHttp(server: Server) {
|
||||
// A sole upgrade listener suppresses Node's default unsupported-upgrade close.
|
||||
// The real app installs its WebSocket handlers before startServer returns.
|
||||
if (server.listenerCount("request") === 0 || server.listenerCount("upgrade") === 0) {
|
||||
throw new Error("Maintenance HTTP observation requires existing app request and upgrade handlers");
|
||||
}
|
||||
const captureId = randomUUID();
|
||||
let requests = 0, installed = true, valid = true;
|
||||
const count = () => {
|
||||
if (requests === Number.MAX_SAFE_INTEGER) valid = false;
|
||||
else requests++;
|
||||
};
|
||||
const detached = (event: string | symbol, listener: unknown) => {
|
||||
if ((event === "request" || event === "upgrade") && listener === count) valid = false;
|
||||
};
|
||||
// Count before application handlers without inspecting or retaining request data.
|
||||
server.prependListener("request", count);
|
||||
server.prependListener("upgrade", count);
|
||||
server.on("removeListener", detached);
|
||||
return {
|
||||
snapshot(): IncomingHttpSnapshot {
|
||||
return { source: "node-http-server-events", captureId, installed,
|
||||
valid: valid && installed && server.listening
|
||||
&& server.listeners("request").includes(count) && server.listeners("upgrade").includes(count),
|
||||
requests, observedAt: new Date().toISOString() };
|
||||
},
|
||||
dispose() {
|
||||
if (!installed) return;
|
||||
installed = false; valid = false;
|
||||
server.off("removeListener", detached);
|
||||
server.off("request", count);
|
||||
server.off("upgrade", count);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { authUsers, companyMemberships, type Db } from '@paperclipai/db';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { cloudTenantPrimaryCompanyId } from '../../../services/cloud-instance.js';
|
||||
import type { MaintenanceConfiguration, MaintenanceServerOptions, startMaintenanceServer } from './server-harness.js';
|
||||
|
||||
export const managedTenantActor = { userId: 'local-board', email: 'sleep-owner@example.test', name: 'Owned sleep operator' };
|
||||
export function managedSleepConfiguration(collectorOrigin: string, serveUi: boolean): MaintenanceConfiguration {
|
||||
return { automaticBackups: { intervalMinutes: 60, idleCheckpoint: true }, announcements: true,
|
||||
telemetryOrigin: `${collectorOrigin}/telemetry`, otelOrigin: collectorOrigin, serveUi,
|
||||
managedInstance: true, deploymentMode: 'authenticated', storageProvider: 's3' };
|
||||
}
|
||||
export function managedSleepServerOptions(configuration: MaintenanceConfiguration, jwks: string, s3Origin: string,
|
||||
broker: { origin: string; variables: Record<string, string> }): MaintenanceServerOptions {
|
||||
return { configuration, controlJwks: jwks, cloud: { jwks, s3Origin, standby: false }, tenantActor: managedTenantActor,
|
||||
githubOrigin: broker.origin, connector: { origin: broker.origin,
|
||||
instanceId: broker.variables.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID,
|
||||
signPrivateKey: broker.variables.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY,
|
||||
sealPrivateKey: broker.variables.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY } };
|
||||
}
|
||||
export async function provisionManagedSleepCompany(server: Awaited<ReturnType<typeof startMaintenanceServer>>, db: Db) {
|
||||
// This real request invokes resolveCloudTenantActor, never a table seed or a bypassed company-create route.
|
||||
const companies = await server.api<Array<{ id: string }>>('GET', '/companies');
|
||||
const id = cloudTenantPrimaryCompanyId('maintenance-stack');
|
||||
assert(companies.some(company => company.id === id), 'Actual managed primary company was not provisioned');
|
||||
const [authority] = await db.select({ userId: authUsers.id, status: companyMemberships.status, membershipRole: companyMemberships.membershipRole })
|
||||
.from(authUsers).innerJoin(companyMemberships, and(eq(companyMemberships.principalId, authUsers.id), eq(companyMemberships.principalType, 'user')))
|
||||
.where(and(eq(authUsers.id, managedTenantActor.userId), eq(companyMemberships.companyId, id)));
|
||||
assert.deepEqual(authority, { userId: managedTenantActor.userId, status: 'active', membershipRole: 'owner' });
|
||||
await server.api('PATCH', `/companies/${id}`, { defaultResponsibleUserId: managedTenantActor.userId });
|
||||
const observation = (await server.snapshot()).managed;
|
||||
assert(observation?.isCloudManaged && observation.deploymentMode === 'authenticated' && observation.storageProvider === 's3', 'Actual supported managed profile not active');
|
||||
return { id, authority, observation, boundary: 'Fixture Cloud front-door headers; actual resolver creates canonical company/user/member/default grants' };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import type { Browser, BrowserContext } from '@playwright/test';
|
||||
|
||||
/** A fresh owned browser, not a user's profile. Never terminate by process name. */
|
||||
export async function withNativeMaintenanceBrowser<T>(run: (browser: Browser, context: BrowserContext) => Promise<T>): Promise<T> {
|
||||
const { chromium } = await import('@playwright/test');
|
||||
const profile = await mkdtemp(path.join(tmpdir(), 'paperclip-sleep-browser-'));
|
||||
let child: ChildProcess | undefined, browser: Browser | undefined, exited: Promise<void> | undefined;
|
||||
let exitObserved = false, launchError: Error | undefined, stderr = '', failure: { error: unknown } | undefined;
|
||||
try {
|
||||
child = spawn(chromium.executablePath(), [`--user-data-dir=${profile}`, '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0',
|
||||
'--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--disable-component-update', '--disable-sync', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
const ownedChild = child;
|
||||
exited = new Promise<void>(resolve => {
|
||||
ownedChild.once('exit', () => { exitObserved = true; resolve(); });
|
||||
ownedChild.once('error', error => { launchError = error; if (!ownedChild.pid) resolve(); });
|
||||
});
|
||||
child.stderr?.on('data', chunk => { stderr = (stderr + String(chunk)).slice(-16_384); });
|
||||
const deadline = performance.now() + 10_000;
|
||||
let port: number | undefined;
|
||||
while (!port) {
|
||||
if (launchError) throw launchError;
|
||||
if (child.exitCode !== null || child.signalCode !== null || performance.now() > deadline) throw new Error(`Owned Chromium startup failed: ${stderr}`);
|
||||
try {
|
||||
const candidate = Number((await readFile(path.join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]);
|
||||
if (Number.isInteger(candidate) && candidate > 0 && candidate < 65536) port = candidate;
|
||||
} catch { /* The owned startup file is not ready yet. */ }
|
||||
if (!port) await delay(50);
|
||||
}
|
||||
// Playwright1.62 installs focus emulation on normal pages. Measured native
|
||||
// tab switches stayed visible there. The supported noDefaults/default-context
|
||||
// path avoids installing that override (types.d.ts ConnectOverCDPOptions).
|
||||
browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { noDefaults: true, timeout: 10_000 });
|
||||
const context = browser.contexts()[0];
|
||||
if (!context) throw new Error('Owned Chromium has no native default context');
|
||||
return await run(browser, context);
|
||||
} catch (error) { failure = { error }; throw error; }
|
||||
finally {
|
||||
const errors: unknown[] = [];
|
||||
let disconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([browser?.close(), new Promise<never>((_, reject) => { disconnectTimer = setTimeout(() => reject(new Error('Owned browser disconnect timed out')), 5_000); })]);
|
||||
} catch (error) { errors.push(error); }
|
||||
finally { if (disconnectTimer) clearTimeout(disconnectTimer); }
|
||||
if (child && exited) {
|
||||
const waitExit = async () => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try { return await Promise.race([exited!.then(() => true), new Promise<boolean>(resolve => { timer = setTimeout(() => resolve(false), 5_000); })]); }
|
||||
finally { if (timer) clearTimeout(timer); }
|
||||
};
|
||||
try {
|
||||
if (child.exitCode === null && child.signalCode === null && !launchError) child.kill('SIGTERM');
|
||||
if (!(await waitExit())) { child.kill('SIGKILL'); if (!(await waitExit())) throw new Error('Owned Chromium did not exit after termination'); }
|
||||
} catch (error) { errors.push(error); }
|
||||
}
|
||||
if (!child?.pid || exitObserved) {
|
||||
try { await rm(profile, { recursive: true, force: true }); } catch (error) { errors.push(error); }
|
||||
} else errors.push(new Error(`Owned Chromium exit was not confirmed; profile preserved at ${profile}`));
|
||||
if (errors.length) throw new AggregateError([...(failure ? [failure.error] : []), ...errors], 'Native browser work or cleanup failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getStartupTracer, instrumentationReady, shutdownInstrumentation } from '../../../instrumentation.js';
|
||||
await instrumentationReady;
|
||||
const span = getStartupTracer('paperclip.sleep-qualification').startSpan('sleep-qualification-stimulus');
|
||||
span.end();
|
||||
// Keep the real SDK alive beyond its 60s default metric-reader interval. This
|
||||
// observes unwanted exporter traffic without disabling any exporter through env.
|
||||
if (process.argv.includes('--observe-exporters')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 90_000));
|
||||
}
|
||||
await shutdownInstrumentation();
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
/** Adapted from the real-SDK loopback fixture in managed-cloud-prerequisites.test.ts.
|
||||
* Small owned objects only; no provider credentials/IAM/durability qualification. */
|
||||
export function createMaintenanceS3Boundary() {
|
||||
const objects = new Map<string, { bytes: Buffer; etag: string; contentType: string }>();
|
||||
const requests: Array<{ method: string; path: string; status: number }> = [];
|
||||
const errors: string[] = [];
|
||||
const xml = (text: string) => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, 'http://fixture.invalid');
|
||||
const operation = { method: req.method!, path: url.pathname + url.search, status: 0 }; requests.push(operation);
|
||||
const reply = (status: number, body?: string | Buffer) => { operation.status = status; res.statusCode = status; res.end(body); };
|
||||
try {
|
||||
if (!url.pathname.startsWith('/maintenance-test/') && url.pathname !== '/maintenance-test') throw new Error('Unknown S3 fixture bucket');
|
||||
const key = decodeURIComponent(url.pathname.slice('/maintenance-test/'.length));
|
||||
if (req.method === 'GET' && ['/maintenance-test', '/maintenance-test/'].includes(url.pathname) && url.searchParams.get('list-type') === '2'
|
||||
&& [...url.searchParams.keys()].every(key => ['list-type', 'prefix', 'max-keys', 'x-id'].includes(key))) {
|
||||
const keys = [...objects.keys()].filter(key => key.startsWith(url.searchParams.get('prefix') ?? '')).sort();
|
||||
if (keys.length > Math.min(1000, Number(url.searchParams.get('max-keys') ?? 1000))) throw new Error('Listing exceeds bounded single-page fixture');
|
||||
res.setHeader('content-type', 'application/xml');
|
||||
reply(200, `<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><IsTruncated>false</IsTruncated>${keys.map(key => `<Contents><Key>${xml(key)}</Key><Size>${objects.get(key)!.bytes.length}</Size></Contents>`).join('')}</ListBucketResult>`); return;
|
||||
}
|
||||
if (!key || [...url.searchParams.keys()].some(key => key !== 'x-id')) throw new Error('Unknown S3 fixture operation/query');
|
||||
if (req.method === 'PUT') {
|
||||
const chunks: Buffer[] = []; let length = 0;
|
||||
for await (const chunk of req) { length += chunk.length; if (length > 17 * 1024 * 1024) throw new Error('S3 fixture object too large'); chunks.push(Buffer.from(chunk)); }
|
||||
let bytes = Buffer.concat(chunks);
|
||||
if (req.headers['content-encoding'] === 'aws-chunked') bytes = decodeMaintenanceAwsChunks(bytes);
|
||||
else if (req.headers['content-encoding']) throw new Error('Unknown S3 fixture content framing');
|
||||
const decodedLength = req.headers['x-amz-decoded-content-length'];
|
||||
if (decodedLength !== undefined && Number(decodedLength) !== bytes.length) throw new Error('S3 decoded length mismatch');
|
||||
const previous = objects.get(key);
|
||||
if ((req.headers['if-none-match'] !== undefined && req.headers['if-none-match'] !== '*')
|
||||
|| (req.headers['if-match'] !== undefined && typeof req.headers['if-match'] !== 'string')) throw new Error('Unknown conditional S3 request');
|
||||
if ((req.headers['if-none-match'] === '*' && previous) || (req.headers['if-match'] && previous?.etag !== req.headers['if-match'])) {
|
||||
res.setHeader('content-type', 'application/xml'); reply(412, '<Error><Code>PreconditionFailed</Code></Error>'); return;
|
||||
}
|
||||
const total = [...objects.values()].reduce((sum, object) => sum + object.bytes.length, 0) - (previous?.bytes.length ?? 0) + bytes.length;
|
||||
if (bytes.length > 16 * 1024 * 1024 || total > 256 * 1024 * 1024 || objects.size >= 2048) throw new Error('S3 fixture storage bound exceeded');
|
||||
const object = { bytes, etag: `"${createHash('md5').update(bytes).digest('hex')}"`, contentType: String(req.headers['content-type'] ?? 'application/octet-stream') };
|
||||
objects.set(key, object); res.setHeader('ETag', object.etag); reply(200); return;
|
||||
}
|
||||
if (req.method === 'DELETE') { objects.delete(key); reply(204); return; }
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') throw new Error('Unknown S3 fixture method');
|
||||
const object = objects.get(key);
|
||||
if (!object) { res.setHeader('content-type', 'application/xml'); reply(404, req.method === 'GET' ? '<Error><Code>NoSuchKey</Code></Error>' : undefined); return; }
|
||||
if (req.headers.range) throw new Error('Range is outside this small-object fixture contract');
|
||||
res.setHeader('ETag', object.etag); res.setHeader('content-type', object.contentType); res.setHeader('content-length', object.bytes.length);
|
||||
reply(200, req.method === 'GET' ? object.bytes : undefined);
|
||||
} catch (error) {
|
||||
errors.push(`${req.method} ${url.pathname + url.search}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (!res.headersSent) { res.setHeader('content-type', 'application/xml'); reply(501, '<Error><Code>NotImplemented</Code></Error>'); } else res.destroy();
|
||||
}
|
||||
});
|
||||
return { server, snapshot: () => ({ boundary: 'Actual AWS SDK HTTP; bounded in-memory S3 protocol fixture, no IAM/SigV4/provider durability proof', requests: requests.map(request => ({ ...request })), errors: [...errors],
|
||||
objects: [...objects].map(([key, object]) => ({ key, bytes: object.bytes.length, sha256: createHash('sha256').update(object.bytes).digest('hex') })) }) };
|
||||
}
|
||||
|
||||
/** Decode SDK checksum framing without pretending to validate provider signatures. */
|
||||
export function decodeMaintenanceAwsChunks(wire: Buffer) {
|
||||
const chunks: Buffer[] = []; let offset = 0;
|
||||
while (offset < wire.length) {
|
||||
const end = wire.indexOf('\r\n', offset); if (end < 0) throw new Error('Malformed AWS chunk header');
|
||||
const header = wire.subarray(offset, end).toString('ascii');
|
||||
if (!/^[0-9a-f]+$/i.test(header)) throw new Error('Unknown AWS chunk extension');
|
||||
const length = Number.parseInt(header, 16); offset = end + 2;
|
||||
if (!Number.isSafeInteger(length) || length < 0 || offset + length + 2 > wire.length) throw new Error('Truncated AWS chunk');
|
||||
if (length === 0) {
|
||||
const trailers = wire.subarray(offset).toString('ascii');
|
||||
if (!/^(?:x-amz-checksum-[a-z0-9]+:[A-Za-z0-9+/=]+\r\n)*\r\n$/.test(trailers)) throw new Error('Unknown AWS checksum trailer');
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
chunks.push(wire.subarray(offset, offset + length)); offset += length;
|
||||
if (wire.subarray(offset, offset + 2).toString('ascii') !== '\r\n') throw new Error('Malformed AWS chunk terminator'); offset += 2;
|
||||
}
|
||||
throw new Error('Missing AWS final chunk');
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { channel } from "node:diagnostics_channel";
|
||||
import { mock } from "node:test";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { Socket } from "node:net";
|
||||
import { observeMaintenanceIncomingHttp } from "./incoming-http-observation.js";
|
||||
|
||||
const realTimeout = globalThis.setTimeout, realInterval = globalThis.setInterval, clearRealInterval = globalThis.clearInterval, realNow = Date.now;
|
||||
const fakeClock = process.env.PAPERCLIP_MAINTENANCE_FAKE_CLOCK === "true";
|
||||
@@ -40,14 +41,42 @@ const observeRequest = () => { outgoingHttpRequests++; };
|
||||
channel("undici:request:create").subscribe(observeRequest);
|
||||
channel("http.client.request.start").subscribe(observeRequest);
|
||||
|
||||
let workCapture: Awaited<ReturnType<typeof import('./idle-work-capture.js')['installMaintenanceIdleWorkCapture']>> | undefined;
|
||||
let databaseCapture: ReturnType<typeof import('@paperclipai/db/response-capture')['databaseResponseCaptureFromEnv']>;
|
||||
let incomingHttp: ReturnType<typeof observeMaintenanceIncomingHttp> | undefined;
|
||||
try {
|
||||
// Must precede the announcement wrapper's first import of the real app graph.
|
||||
if (process.env.PAPERCLIP_MAINTENANCE_IDLE_WORK_CAPTURE === '1') {
|
||||
workCapture = await (await import('./idle-work-capture.js')).installMaintenanceIdleWorkCapture();
|
||||
}
|
||||
const announcement = process.env.PAPERCLIP_MAINTENANCE_ANNOUNCEMENT_FIXTURE === '1'
|
||||
? await (await import('./announcement-boundary.js')).installMaintenanceAnnouncementBoundary() : undefined;
|
||||
const { startServer } = await import("../../../index.js");
|
||||
const { idleWorkSnapshot, startTaskDrain, stopTaskDrain } = await import("../../../services/task-admission.js");
|
||||
const { getStartupRecoveryState } = await import("../../../startup-recovery-state.js");
|
||||
const server = await startServer();
|
||||
if (workCapture) databaseCapture = (await import('@paperclipai/db/response-capture')).databaseResponseCaptureFromEnv();
|
||||
const managedObservation = process.env.PAPERCLIP_MAINTENANCE_MANAGED_TENANT === '1' ? await (async () => {
|
||||
const { loadConfig } = await import('../../../config.js');
|
||||
const { isCloudManagedInstance } = await import('../../../services/cloud-instance.js');
|
||||
const { idleApiActivity } = await import('../../../services/idle-api-activity.js');
|
||||
const config = loadConfig();
|
||||
return () => ({ isCloudManaged: isCloudManagedInstance(), deploymentMode: config.deploymentMode,
|
||||
storageProvider: config.storageProvider, apiActivityActive: idleApiActivity.active() });
|
||||
})() : undefined;
|
||||
const app = server.server.listeners("request")[0] as unknown as { locals: { paperclipDb: Db } };
|
||||
try { incomingHttp = observeMaintenanceIncomingHttp(server.server); }
|
||||
catch (error) {
|
||||
try { await server.shutdown(); }
|
||||
catch (cleanupError) { throw new AggregateError([error, cleanupError], "Incoming HTTP observation and owned startup cleanup failed"); }
|
||||
throw error;
|
||||
}
|
||||
async function shutdownServer() {
|
||||
try { await server.shutdown(); } finally { incomingHttp?.dispose(); }
|
||||
}
|
||||
try { announcement?.assertIntercepted(); } catch (error) { await shutdownServer(); throw error; }
|
||||
process.send?.({ kind: "ready", port: server.listenPort });
|
||||
process.on("message", async (message: { id: number; command: string; ms?: number; issueUpdate?: { id: string; status: "done" }; orphanLease?: { companyId: string }; publication?: { companyId: string; scopeDigest: string; backupRoot: string; pendingRoot: string } }) => {
|
||||
process.on("message", async (message: { id: number; command: string; ms?: number; issueUpdate?: { id: string; status: "done" }; orphanLease?: { companyId: string }; publication?: { companyId: string; scopeDigest: string; backupRoot: string; pendingRoot: string }; github?: { companyId?: string; connectionId?: string; grantId?: string } }) => {
|
||||
try {
|
||||
if (message.command === "snapshot" || message.command === "advance" || message.command === "drain" || message.command === "resume" || message.command === "freeze" || message.command === "realtime") {
|
||||
if (message.command === "freeze" && bootClock) { clearRealInterval(bootClock); bootClock = realInterval(() => mock.timers.tick(0), 10); }
|
||||
@@ -59,7 +88,23 @@ try {
|
||||
if (message.command === "drain") startTaskDrain({ purpose: "idle" });
|
||||
if (message.command === "resume") stopTaskDrain();
|
||||
if (fakeClock) mock.timers.tick(0);
|
||||
process.send?.({ id: message.id, kind: "snapshot", idle: idleWorkSnapshot(), startup: getStartupRecoveryState(), outgoingHttpRequests, network: network(), now: Date.now() });
|
||||
process.send?.({ id: message.id, kind: "snapshot", idle: idleWorkSnapshot(), startup: getStartupRecoveryState(), outgoingHttpRequests, network: network(), incomingHttp: incomingHttp?.snapshot(), now: Date.now(), announcement: announcement?.snapshot(), managed: managedObservation?.(), workCapture: workCapture?.snapshot() });
|
||||
} else if (message.command === "prepareGitHub" || message.command === "settleGitHub") {
|
||||
const { prepareGitHubConfiguration, settleGitHubConfiguration } = await import("./github-configuration-producer.js");
|
||||
const input = message.github;
|
||||
if (!input) throw new Error("Missing GitHub fixture identity");
|
||||
const result = message.command === "prepareGitHub"
|
||||
? await prepareGitHubConfiguration(app.locals.paperclipDb, input.companyId!)
|
||||
: await settleGitHubConfiguration(app.locals.paperclipDb, { connectionId: input.connectionId!, grantId: input.grantId! });
|
||||
process.send?.({ id: message.id, kind: "githubConfiguration", result });
|
||||
} else if (message.command === "produceChatHistory") {
|
||||
const { produceChatHistory } = await import("./chat-history-producer.js");
|
||||
process.send?.({ id: message.id, kind: "chatHistory", result: await produceChatHistory(app.locals.paperclipDb) });
|
||||
} else if (message.command === "trace") {
|
||||
const { getStartupTracer } = await import("../../../instrumentation.js");
|
||||
const span = getStartupTracer("paperclip.sleep-qualification").startSpan("sleep-qualification-stimulus");
|
||||
span.end();
|
||||
process.send?.({ id: message.id, kind: "traceEnded" });
|
||||
} else if (message.command === "createOrphanLease") {
|
||||
if (!message.orphanLease) throw new Error("Missing orphan lease fixture");
|
||||
const { environmentLeases } = await import("@paperclipai/db");
|
||||
@@ -83,7 +128,20 @@ try {
|
||||
process.send?.({ id: message.id, kind: "published" });
|
||||
} else if (message.command === "shutdown") {
|
||||
if (fakeClock) runClock();
|
||||
await server.shutdown();
|
||||
if (workCapture) {
|
||||
const failures: unknown[] = [];
|
||||
let before: ReturnType<typeof workCapture.summary> | undefined;
|
||||
try { before = workCapture.summary(); } catch (error) { failures.push(error); }
|
||||
try { await shutdownServer(); } catch (error) { failures.unshift(error); }
|
||||
// A passive flush failure must not replace the original shutdown error.
|
||||
try { databaseCapture?.flush(); } catch (error) { failures.push(error); }
|
||||
try {
|
||||
await (await import('node:fs/promises')).writeFile((await import('node:path')).join(process.env.PAPERCLIP_HOME!, 'idle-work-capture-cleanup.json'), JSON.stringify({ before, after: workCapture.summary() }, null, 2));
|
||||
} catch (error) { failures.push(error); }
|
||||
finally { try { workCapture.dispose(); } catch (error) { failures.push(error); } }
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'Owned shutdown and passive evidence failed');
|
||||
if (failures.length) throw failures[0];
|
||||
} else await shutdownServer();
|
||||
if (bootClock) { clearRealInterval(bootClock); bootClock = undefined; }
|
||||
process.send?.({ id: message.id, kind: "stopped", outgoingHttpRequests });
|
||||
if (fakeClock) mock.timers.reset();
|
||||
@@ -93,6 +151,16 @@ try {
|
||||
} catch (error) { process.send?.({ id: message.id, kind: "error", error: error instanceof Error ? error.stack : String(error) }); }
|
||||
});
|
||||
} catch (error) {
|
||||
incomingHttp?.dispose();
|
||||
if (workCapture) {
|
||||
try {
|
||||
// Existing bounded DB capture performs no SQL while flushing metadata.
|
||||
databaseCapture ??= (await import('@paperclipai/db/response-capture')).databaseResponseCaptureFromEnv();
|
||||
databaseCapture?.flush();
|
||||
await (await import('node:fs/promises')).writeFile((await import('node:path')).join(process.env.PAPERCLIP_HOME!, 'idle-work-capture-startup-failure.json'), JSON.stringify(workCapture.summary(), null, 2));
|
||||
} catch { console.warn('Passive startup evidence could not be persisted; original error preserved'); }
|
||||
finally { try { workCapture.dispose(); } catch { console.warn('Passive capture cleanup failed; original startup error preserved'); } }
|
||||
}
|
||||
if (bootClock) clearRealInterval(bootClock);
|
||||
process.send?.({ kind: "error", error: error instanceof Error ? error.stack : String(error) });
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { IdleWorkCaptureSnapshot } from './idle-work-capture.js';
|
||||
import type { IncomingHttpSnapshot } from '../../../../../scripts/sleep-qualification/evidence.mjs';
|
||||
import { fork } from "node:child_process";
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
@@ -42,9 +44,54 @@ export async function withMaintenanceProvider<T>(provider: HttpServer, run: (ori
|
||||
}
|
||||
}
|
||||
|
||||
type Snapshot = { idle: { active: number; generation: number }; startup: { phase: string }; outgoingHttpRequests: number; network: { nonDatabaseConnections: number; databaseConnections: number; nonDatabaseBytesWritten: number }; now: number };
|
||||
type Snapshot = { idle: { active: number; generation: number }; startup: { phase: string }; outgoingHttpRequests: number; network: { nonDatabaseConnections: number; databaseConnections: number; nonDatabaseBytesWritten: number }; now: number;
|
||||
incomingHttp: IncomingHttpSnapshot;
|
||||
managed?: { isCloudManaged: boolean; deploymentMode: string; storageProvider: string; apiActivityActive: boolean };
|
||||
workCapture?: IdleWorkCaptureSnapshot;
|
||||
announcement?: { intercepted: boolean; requests: string[]; unmatched: string[]; boundary: string } };
|
||||
|
||||
export async function startMaintenanceServer(databaseUrl: string, options: { artifactRoot?: string; enabled?: boolean; clock?: boolean; suppressed?: boolean; githubOrigin?: string; connector?: { origin: string; signPrivateKey: string; sealPrivateKey: string }; cloud?: { jwks: string; s3Origin: string; standby: boolean } } = {}) {
|
||||
export interface MaintenanceConfiguration {
|
||||
automaticBackups?: { intervalMinutes: number; idleCheckpoint: boolean };
|
||||
announcements?: boolean;
|
||||
telemetryOrigin?: string;
|
||||
otelOrigin?: string;
|
||||
serveUi?: boolean;
|
||||
managedInstance?: boolean;
|
||||
deploymentMode?: "local_trusted" | "authenticated";
|
||||
storageProvider?: "local_disk" | "s3";
|
||||
}
|
||||
export interface MaintenanceServerOptions {
|
||||
artifactRoot?: string; enabled?: boolean; clock?: boolean; suppressed?: boolean; githubOrigin?: string;
|
||||
connector?: { origin: string; signPrivateKey: string; sealPrivateKey: string; instanceId?: string };
|
||||
cloud?: { jwks: string; s3Origin: string; standby: boolean };
|
||||
configuration?: MaintenanceConfiguration;
|
||||
controlJwks?: string;
|
||||
captureIdleWork?: boolean;
|
||||
tenantActor?: { userId: string; email: string; name: string };
|
||||
}
|
||||
export async function startMaintenanceServer(databaseUrl: string, options: MaintenanceServerOptions = {}) {
|
||||
const configuration = options.configuration;
|
||||
if (options.captureIdleWork && options.clock) throw new Error("Passive setup diagnostics require the real clock");
|
||||
if (options.tenantActor && (!options.cloud || !configuration?.managedInstance || configuration.deploymentMode !== 'authenticated' || configuration.storageProvider !== 's3')) {
|
||||
throw new Error('Managed tenant fixture requires the supported authenticated S3 profile');
|
||||
}
|
||||
const tenantHeaders: Record<string, string> = options.tenantActor ? {
|
||||
'x-paperclip-cloud-tenant-token': 'maintenance-tenant-token', 'x-paperclip-cloud-stack-id': 'maintenance-stack',
|
||||
'x-paperclip-cloud-stack-role': 'owner', 'x-paperclip-cloud-user-id': options.tenantActor.userId,
|
||||
'x-paperclip-cloud-user-email': options.tenantActor.email, 'x-paperclip-cloud-user-name': options.tenantActor.name,
|
||||
'x-paperclip-cloud-paperclip-company-name': 'Configured sleep acceptance',
|
||||
} : {};
|
||||
for (const origin of [configuration?.telemetryOrigin, configuration?.otelOrigin]) {
|
||||
if (!origin) continue;
|
||||
const endpoint = new URL(origin);
|
||||
if (endpoint.protocol !== "http:" || endpoint.hostname !== "127.0.0.1" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
||||
throw new Error("Maintenance collectors must be explicit loopback HTTP endpoints");
|
||||
}
|
||||
}
|
||||
if (configuration?.automaticBackups && (!Number.isInteger(configuration.automaticBackups.intervalMinutes) || configuration.automaticBackups.intervalMinutes < 1)) {
|
||||
throw new Error("Maintenance backup interval must be a positive integer");
|
||||
}
|
||||
if (configuration && options.clock) throw new Error("Configured acceptance requires the real clock");
|
||||
const artifacts = await createMaintenanceArtifacts(options.artifactRoot);
|
||||
const { directory, retainedDirectory } = artifacts;
|
||||
const reservation = createServer();
|
||||
@@ -72,43 +119,57 @@ export async function startMaintenanceServer(databaseUrl: string, options: { art
|
||||
const configPath = path.join(directory, "config.json");
|
||||
await writeFile(configPath, JSON.stringify({
|
||||
$meta: { version: 1, updatedAt: new Date().toISOString(), source: "onboard" },
|
||||
database: { mode: "postgres", connectionString: proxy.url, backup: { enabled: false, dir: path.join(directory, "backups") } },
|
||||
database: { mode: "postgres", connectionString: proxy.url, backup: { enabled: Boolean(configuration?.automaticBackups), intervalMinutes: configuration?.automaticBackups?.intervalMinutes ?? 60, dir: path.join(directory, "backups") } },
|
||||
logging: { mode: "file", logDir: path.join(directory, "logs") },
|
||||
server: { deploymentMode: "local_trusted", exposure: "private", bind: "loopback", host: "127.0.0.1", port: address.port, serveUi: false },
|
||||
server: { deploymentMode: options.tenantActor ? "authenticated" : "local_trusted", exposure: "private", bind: "loopback", host: "127.0.0.1", port: address.port, serveUi: configuration?.serveUi ?? false },
|
||||
storage: options.cloud ? { provider: "s3", s3: { bucket: "maintenance-test", region: "us-east-1", endpoint: options.cloud.s3Origin, forcePathStyle: true } }
|
||||
: { provider: "local_disk", localDisk: { baseDir: path.join(directory, "storage") } },
|
||||
secrets: { provider: "local_encrypted", strictMode: false, localEncrypted: { keyFilePath: path.join(directory, "master.key") } },
|
||||
telemetry: { enabled: false }, updates: { checkEnabled: false },
|
||||
...(options.tenantActor ? { auth: { baseUrlMode: "explicit", publicBaseUrl: `http://127.0.0.1:${address.port}`, disableSignUp: true } } : {}),
|
||||
telemetry: { enabled: Boolean(configuration?.telemetryOrigin) }, updates: { checkEnabled: false },
|
||||
}), { mode: 0o600 });
|
||||
await new Promise<void>(resolve => reservation.close(() => resolve()));
|
||||
const captureStartedAt = options.captureIdleWork ? Date.now() : undefined;
|
||||
const captureSources = options.captureIdleWork ? new Map<number, string[]>() : undefined;
|
||||
let settledDiagnosticId = 0;
|
||||
const child = fork(fileURLToPath(new URL("./server-child.ts", import.meta.url)), [], {
|
||||
cwd: fileURLToPath(new URL("../../../../..", import.meta.url)),
|
||||
execArgv: ["--import", fileURLToPath(new URL("../../../../node_modules/tsx/dist/loader.mjs", import.meta.url))],
|
||||
execArgv: [...((configuration?.announcements || options.captureIdleWork) ? ['--experimental-test-module-mocks'] : []), "--import", fileURLToPath(new URL("../../../../node_modules/tsx/dist/loader.mjs", import.meta.url))],
|
||||
env: {
|
||||
...(captureStartedAt === undefined ? {} : {
|
||||
PAPERCLIP_MAINTENANCE_IDLE_WORK_CAPTURE: '1',
|
||||
PAPERCLIP_DB_RESPONSE_CAPTURE_WINDOW: `${new Date(captureStartedAt).toISOString()}/${new Date(captureStartedAt + 60_000).toISOString()}`,
|
||||
}),
|
||||
PATH: process.env.PATH, TMPDIR: process.env.TMPDIR, NODE_ENV: "production",
|
||||
PAPERCLIP_HOME: directory, PAPERCLIP_CONFIG: configPath, PAPERCLIP_INSTANCE_ID: "maintenance-test",
|
||||
PAPERCLIP_RUNNER_STATE_DIR: path.join(directory, "durable-sessions"),
|
||||
DATABASE_URL: proxy.url, DATABASE_POOL_MAX: "6", DATABASE_APPLICATION_NAME: "maintenance-integration-test",
|
||||
DATABASE_URL: proxy.url, DATABASE_POOL_MAX: configuration ? "4" : "6", DATABASE_APPLICATION_NAME: "maintenance-integration-test",
|
||||
PAPERCLIP_AGENT_JWT_SECRET: randomUUID(), PAPERCLIP_DECISION_SIGNING_SECRET: randomUUID(),
|
||||
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: randomUUID(), BETTER_AUTH_SECRET: randomUUID(),
|
||||
CODEX_HOME: path.join(directory, "codex"), PAPERCLIP_ANNOUNCEMENTS_ENABLED: "false",
|
||||
PAPERCLIP_DB_BACKUP_ENABLED: "false", PAPERCLIP_UI_DEV_MIDDLEWARE: "false",
|
||||
CODEX_HOME: path.join(directory, "codex"), PAPERCLIP_ANNOUNCEMENTS_ENABLED: String(configuration?.announcements ?? false),
|
||||
...(configuration?.announcements ? { PAPERCLIP_MAINTENANCE_ANNOUNCEMENT_FIXTURE: '1' } : {}),
|
||||
PAPERCLIP_DB_BACKUP_ENABLED: String(Boolean(configuration?.automaticBackups)), PAPERCLIP_UI_DEV_MIDDLEWARE: "false",
|
||||
...(configuration?.automaticBackups ? { PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED: configuration.automaticBackups.idleCheckpoint ? "1" : "0" } : {}),
|
||||
...(configuration?.telemetryOrigin ? { PAPERCLIP_TELEMETRY_ENDPOINT: configuration.telemetryOrigin } : {}),
|
||||
...(configuration?.otelOrigin ? { OTEL_EXPORTER_OTLP_ENDPOINT: configuration.otelOrigin, OTEL_EXPORTER_OTLP_PROTOCOL: "http/json" } : {}),
|
||||
HEARTBEAT_SCHEDULER_ENABLED: String(options.enabled ?? true),
|
||||
PAPERCLIP_MAINTENANCE_FAKE_CLOCK: String(options.clock ?? false),
|
||||
...(options.suppressed ? { PAPERCLIP_IN_WORKTREE: "true" } : {}),
|
||||
...(options.githubOrigin ? { PAPERCLIP_MAINTENANCE_GITHUB_ORIGIN: options.githubOrigin } : {}),
|
||||
...(options.connector ? {
|
||||
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: "maintenance-connector", PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "test",
|
||||
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: options.connector.instanceId ?? "maintenance-connector", PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "test",
|
||||
PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: options.connector.signPrivateKey,
|
||||
PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: options.connector.sealPrivateKey,
|
||||
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: options.connector.origin,
|
||||
} : {}),
|
||||
...(options.controlJwks ? { PAPERCLIP_CLOUD_STACK_ID: "maintenance-stack", PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: options.controlJwks } : {}),
|
||||
...(options.cloud ? {
|
||||
PAPERCLIP_MANAGED_CONFIG: JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "2026.720.0", features: {}, plugins: { autoInstall: [] } }),
|
||||
PAPERCLIP_SECRETS_MASTER_KEY: randomBytes(32).toString("hex"),
|
||||
PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "maintenance-tenant-token", PAPERCLIP_CLOUD_STACK_ID: "maintenance-stack",
|
||||
PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: options.cloud.jwks, PAPERCLIP_CLOUD_WARM_STANDBY: options.cloud.standby ? "1" : "0",
|
||||
PAPERCLIP_PUBLIC_URL: "https://pool-maintenance.staging.paperclip.app", PAPERCLIP_PRIMARY_HOST: "pool-maintenance.staging.paperclip.app",
|
||||
PAPERCLIP_PUBLIC_URL: options.tenantActor ? `http://127.0.0.1:${address.port}` : "https://pool-maintenance.staging.paperclip.app", PAPERCLIP_PRIMARY_HOST: options.tenantActor ? "127.0.0.1" : "pool-maintenance.staging.paperclip.app",
|
||||
...(options.tenantActor ? { PAPERCLIP_MAINTENANCE_MANAGED_TENANT: "1" } : {}),
|
||||
AWS_ACCESS_KEY_ID: "maintenance-fixture", AWS_SECRET_ACCESS_KEY: "maintenance-fixture", AWS_EC2_METADATA_DISABLED: "true",
|
||||
} : {}),
|
||||
PAPERCLIP_MIGRATION_PROMPT: "never", PAPERCLIP_MIGRATION_AUTO_APPLY: "true", PAPERCLIP_MANAGED_RUNTIME_HTTPS: "off",
|
||||
@@ -139,12 +200,18 @@ export async function startMaintenanceServer(databaseUrl: string, options: { art
|
||||
for (const request of requests.values()) request.reject(error);
|
||||
requests.clear();
|
||||
});
|
||||
async function request<T>(command: string, payload: { ms?: number; issueUpdate?: { id: string; status: "done" }; orphanLease?: { companyId: string }; publication?: { companyId: string; scopeDigest: string; backupRoot: string; pendingRoot: string } } = {}): Promise<T> {
|
||||
async function request<T>(command: string, payload: { ms?: number; issueUpdate?: { id: string; status: "done" }; orphanLease?: { companyId: string }; publication?: { companyId: string; scopeDigest: string; backupRoot: string; pendingRoot: string }; github?: { companyId?: string; connectionId?: string; grantId?: string } } = {}): Promise<T> {
|
||||
const id = ++requestId;
|
||||
const result = new Promise<T>((resolve, reject) => requests.set(id, { resolve, reject }));
|
||||
child.send({ id, command, ...payload });
|
||||
try { return await withDeadline(result, 30_000, () => `Maintenance child ${command} timed out: ${output.slice(-8_000)}`); }
|
||||
finally { requests.delete(id); }
|
||||
try {
|
||||
const value = await withDeadline(result, 30_000, () => `Maintenance child ${command} timed out: ${output.slice(-8_000)}`);
|
||||
if (captureSources) {
|
||||
const capture = (value as { workCapture?: IdleWorkCaptureSnapshot }).workCapture;
|
||||
for (const source of capture?.newSources ?? []) captureSources.set(source.id, source.callers);
|
||||
}
|
||||
return value;
|
||||
} finally { requests.delete(id); }
|
||||
}
|
||||
stopChild = async () => {
|
||||
try {
|
||||
@@ -165,7 +232,7 @@ export async function startMaintenanceServer(databaseUrl: string, options: { art
|
||||
if (options.clock) await request("realtime");
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api${route}`, { method, signal: AbortSignal.timeout(30_000),
|
||||
headers: { "Content-Type": "application/json", ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
headers: { "Content-Type": "application/json", ...(Object.keys(headers ?? {}).some(key => ["authorization", "x-paperclip-cloud-control"].includes(key.toLowerCase())) ? {} : tenantHeaders), ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
const value = await response.json();
|
||||
if (!response.ok) throw new Error(`Maintenance API ${method} ${route}: ${response.status} ${JSON.stringify(value)}`);
|
||||
return value as T;
|
||||
@@ -182,6 +249,16 @@ export async function startMaintenanceServer(databaseUrl: string, options: { art
|
||||
finally { if (options.clock) await request("freeze"); }
|
||||
},
|
||||
publishBackup: (publication: { companyId: string; scopeDigest: string; backupRoot: string; pendingRoot: string }) => request("publishBackup", { publication }),
|
||||
async prepareGitHub(companyId: string) {
|
||||
const result = await request<{ result: { connectionId: string; grantId: string } }>("prepareGitHub", { github: { companyId } });
|
||||
return result.result;
|
||||
},
|
||||
async settleGitHub(input: { connectionId: string; grantId: string }) {
|
||||
return request("settleGitHub", { github: input });
|
||||
},
|
||||
tenantHeaders,
|
||||
produceChatHistory: () => request("produceChatHistory"),
|
||||
trace: () => request("trace"),
|
||||
snapshot: () => request<Snapshot>("snapshot"),
|
||||
drain: () => request<Snapshot>("drain"), resume: () => request<Snapshot>("resume"),
|
||||
realtime: () => request<Snapshot>("realtime"),
|
||||
@@ -197,17 +274,38 @@ export async function startMaintenanceServer(databaseUrl: string, options: { art
|
||||
},
|
||||
freeze: () => request<Snapshot>("freeze"),
|
||||
get output() { return output; },
|
||||
async settled(quietMs = 500, deferredStartupHolds = 0) {
|
||||
async settled(quietMs = 500, deferredStartupHolds = 0, settleOptions: { managedSetupRecovery?: true } = {}) {
|
||||
// A successful managed health check self-notifies, then reconciles its
|
||||
// future deadline on the next existing 30s pass. Observe both passes +
|
||||
// 500ms quiet + 500ms margin without changing worker timing/admission.
|
||||
const settleTimeoutMs = settleOptions.managedSetupRecovery ? 61_000 : 30_000;
|
||||
const diagnostic = options.captureIdleWork ? (await import('./idle-work-capture.js')).createSettledWorkSamples(
|
||||
() => [...captureSources!].map(([id, callers]) => ({ id, callers }))) : undefined;
|
||||
const diagnosticId = diagnostic ? ++settledDiagnosticId : undefined;
|
||||
let failure: { error: unknown } | undefined;
|
||||
let previous = -1, quietSince = Date.now();
|
||||
for (const deadline = Date.now() + 30_000; Date.now() < deadline;) {
|
||||
try {
|
||||
for (const deadline = Date.now() + settleTimeoutMs; Date.now() < deadline;) {
|
||||
const snapshot = await request<Snapshot>("snapshot");
|
||||
diagnostic?.sample(snapshot, proxy.queries.length, proxy.pending);
|
||||
if (proxy.errors.length) throw new AggregateError(proxy.errors, "PostgreSQL observer failed");
|
||||
if (snapshot.idle.active !== deferredStartupHolds || proxy.pending !== 0 || proxy.queries.length !== previous) quietSince = Date.now();
|
||||
previous = proxy.queries.length;
|
||||
if (snapshot.idle.active === deferredStartupHolds && proxy.pending === 0 && Date.now() - quietSince >= quietMs) {
|
||||
diagnostic?.assertComplete(); return snapshot;
|
||||
}
|
||||
await delay(25);
|
||||
}
|
||||
const snapshot = await request<Snapshot>("snapshot");
|
||||
if (proxy.errors.length) throw new AggregateError(proxy.errors, "PostgreSQL observer failed");
|
||||
if (snapshot.idle.active !== deferredStartupHolds || proxy.pending !== 0 || proxy.queries.length !== previous) quietSince = Date.now();
|
||||
previous = proxy.queries.length;
|
||||
if (snapshot.idle.active === deferredStartupHolds && proxy.pending === 0 && Date.now() - quietSince >= quietMs) return snapshot;
|
||||
await delay(25);
|
||||
diagnostic?.sample(snapshot, proxy.queries.length, proxy.pending);
|
||||
throw new Error(`Maintenance did not settle: ${JSON.stringify({ timeoutMs: settleTimeoutMs, quietMs, queries: proxy.queries.length, pending: proxy.pending, snapshot })}\n${output.slice(-12_000)}`);
|
||||
} catch (error) { failure = { error }; throw error; }
|
||||
finally {
|
||||
if (diagnostic) {
|
||||
try { await writeFile(path.join(directory, `idle-work-settled-samples-${diagnosticId}.json`), JSON.stringify({ ...diagnostic.report(), settleDeadlineMs: settleTimeoutMs }, null, 2)); }
|
||||
catch (error) { if (failure) throw new AggregateError([failure.error, error], 'Original settling and passive evidence failed'); throw error; }
|
||||
}
|
||||
}
|
||||
throw new Error(`Maintenance did not settle: ${JSON.stringify({ queries: proxy.queries.length, pending: proxy.pending, snapshot: await request("snapshot") })}\n${output.slice(-12_000)}`);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as tables from "@paperclipai/db";
|
||||
import type { NativeExecutionInputV1, NativeExecutionInputV3 } from "@paperclipai/paperclip-runner";
|
||||
import { startRecoveryPostgres } from "./recovery-postgres.js";
|
||||
import { prepareNativeHeartbeatRun } from "../../services/native-runtime/prepare-native-run.js";
|
||||
import { buildNativeCompletionContract } from "../../services/native-runtime/completion-contracts.js";
|
||||
import { nativeRuntimeContextFixture } from "../../services/native-runtime/runtime-context.test-fixture.js";
|
||||
|
||||
export async function createNativeWarmIngressFixture(base: NativeExecutionInputV1, cwd: string) {
|
||||
const database = await startRecoveryPostgres("shared-ingress-owner-");
|
||||
const db = tables.createDb(database.connectionString);
|
||||
const companyId = randomUUID(), agentId = randomUUID(), environmentId = randomUUID(), workspaceId = randomUUID();
|
||||
const providerLeaseId = `allocation-${randomUUID()}`;
|
||||
await db.insert(tables.companies).values({ id: companyId, name: "Shared ingress", issuePrefix: `I${companyId.slice(0, 7)}` });
|
||||
await db.insert(tables.agents).values({ id: agentId, companyId, name: "Shared worker", adapterType: "paperclip_runner" });
|
||||
await db.insert(tables.environments).values({ id: environmentId, name: "Shared allocation", driver: "sandbox" });
|
||||
await db.insert(tables.executionWorkspaces).values({ id: workspaceId, companyId, name: "Shared workspace", mode: "shared_workspace",
|
||||
strategyType: "project_primary", providerType: "cloud_sandbox", cwd });
|
||||
const defaultCompanyId = companyId, defaultAgentId = agentId, defaultWorkspaceId = workspaceId, defaultProviderLeaseId = providerLeaseId;
|
||||
let epoch = 0;
|
||||
async function admit(options?: { companyId?: string; agentId?: string; workspaceId?: string; providerLeaseId?: string }) {
|
||||
const ownerCompanyId = options?.companyId ?? defaultCompanyId, ownerAgentId = options?.agentId ?? defaultAgentId;
|
||||
const ownerWorkspaceId = options?.workspaceId ?? defaultWorkspaceId, ownerProviderLeaseId = options?.providerLeaseId ?? defaultProviderLeaseId;
|
||||
const companyId = ownerCompanyId, agentId = ownerAgentId, workspaceId = ownerWorkspaceId, providerLeaseId = ownerProviderLeaseId;
|
||||
const issueId = randomUUID(), runId = randomUUID(), leaseId = randomUUID();
|
||||
const [issue] = await db.insert(tables.issues).values({ id: issueId, companyId, title: "Shared handoff", status: "in_progress",
|
||||
assigneeAgentId: agentId, executionWorkspaceId: workspaceId }).returning();
|
||||
const [run] = await db.insert(tables.heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId,
|
||||
status: "running", runtimeMode: "native", contextSnapshot: { issueId } }).returning();
|
||||
await db.update(tables.issues).set({ executionRunId: runId }).where(eq(tables.issues.id, issueId));
|
||||
await db.insert(tables.environmentLeases).values({ id: leaseId, companyId, environmentId, executionWorkspaceId: workspaceId,
|
||||
issueId, heartbeatRunId: runId, provider: "daytona", providerLeaseId, leasePolicy: "reuse_by_environment" });
|
||||
await db.insert(tables.nativeRunFinalizations).values({ runId, companyId, issueId, phase: "observed" });
|
||||
await prepareNativeHeartbeatRun({ db, run, issue, environmentLeaseId: leaseId });
|
||||
const [contract] = await db.select().from(tables.completionContracts).where(eq(tables.completionContracts.issueId, issueId));
|
||||
const execution: NativeExecutionInputV3 = { ...base, schema: "paperclip.native-execution-input.v3", executionMode: "default", planningContext: null,
|
||||
runtimeContext: nativeRuntimeContextFixture(), binding: { companyId, agentId, issueId, runId, executionWorkspaceId: workspaceId },
|
||||
completionContract: { id: contract.id, sha256: contract.canonicalSha256, schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: buildNativeCompletionContract(issue, { revision: contract.revision }) },
|
||||
workspace: { cwd, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { ...base.session, normalizedSessionId: `logical-${issueId}`, lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 } } };
|
||||
await db.update(tables.executionWorkspaces).set({ metadata: { remoteWorkspace: {
|
||||
schema: "paperclip.remote-workspace.v1", epoch: ++epoch, snapshotId: null, source: { kind: "empty" }, writerRunId: runId, dirty: true,
|
||||
location: { environmentId, leaseId, providerLeaseId, remoteCwd: cwd },
|
||||
} } }).where(eq(tables.executionWorkspaces.id, workspaceId));
|
||||
const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", environmentId, leaseId, remoteCwd: cwd,
|
||||
effectiveCapabilities: { runnerWebSocketIngress: true } as never,
|
||||
sandboxLeaseAcquisition: { outcome: "resumed" as const, providerLeaseId } };
|
||||
return { execution, target, issueId, runId, leaseId };
|
||||
}
|
||||
async function settle(owner: Awaited<ReturnType<typeof admit>>, result: Record<string, unknown>) {
|
||||
const resultId = randomUUID();
|
||||
await db.insert(tables.nativeRunResults).values({ id: resultId, companyId, issueId: owner.issueId, runId: owner.runId,
|
||||
completionContractId: owner.execution.completionContract.id, schemaStatus: "accepted", resultJson: result,
|
||||
serverFingerprint: randomUUID(), canonicalSha256: randomUUID() });
|
||||
await db.update(tables.nativeRunFinalizations).set({ phase: "committed", resultId, leaseOwner: null, leaseExpiresAt: null }).where(eq(tables.nativeRunFinalizations.runId, owner.runId));
|
||||
await db.update(tables.heartbeatRuns).set({ status: "succeeded", finishedAt: new Date(), nativePhase: "committed", costAccountingPending: false }).where(eq(tables.heartbeatRuns.id, owner.runId));
|
||||
await db.update(tables.issues).set({ status: "done", executionRunId: null, checkoutRunId: null }).where(eq(tables.issues.id, owner.issueId));
|
||||
await db.update(tables.environmentLeases).set({ status: "expired", cleanupStatus: "success", releasedAt: new Date() }).where(eq(tables.environmentLeases.id, owner.leaseId));
|
||||
return resultId;
|
||||
}
|
||||
return { db, companyId, agentId, environmentId, workspaceId, providerLeaseId, admit, settle, cleanup: () => database.cleanup() };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
const sdk = vi.hoisted(() => ({ options: [] as Array<Record<string, unknown>>, start: vi.fn(), shutdown: vi.fn(async () => {}) }));
|
||||
vi.mock('../peer-version-check.js', () => ({ checkExactPeerVersions: () => ({ ok: true }) }));
|
||||
vi.mock('@opentelemetry/sdk-node', () => ({ NodeSDK: class {
|
||||
constructor(options: Record<string, unknown>) { sdk.options.push(options); }
|
||||
start = sdk.start;
|
||||
shutdown = sdk.shutdown;
|
||||
} }));
|
||||
vi.mock('@opentelemetry/auto-instrumentations-node', () => ({ getNodeAutoInstrumentations: () => [] }));
|
||||
vi.mock('@opentelemetry/resources', () => ({ resourceFromAttributes: (attributes: unknown) => attributes }));
|
||||
vi.mock('@opentelemetry/semantic-conventions', () => ({ ATTR_SERVICE_NAME: 'service.name', ATTR_SERVICE_VERSION: 'service.version' }));
|
||||
vi.mock('@opentelemetry/exporter-trace-otlp-http', () => ({ OTLPTraceExporter: class {} }));
|
||||
|
||||
afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); });
|
||||
it('configures current plural SDK signal options so an opted-in trace exporter cannot enable default metrics or logs', async () => {
|
||||
vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://collector.invalid:4318');
|
||||
vi.stubEnv('OTEL_EXPORTER_OTLP_PROTOCOL', 'http/json');
|
||||
vi.spyOn(process, 'once').mockReturnValue(process);
|
||||
vi.resetModules();
|
||||
const instrumentation = await import('../instrumentation.js');
|
||||
await instrumentation.instrumentationReady;
|
||||
expect(sdk.options).toHaveLength(1);
|
||||
expect(sdk.options[0]).toMatchObject({ metricReaders: [], logRecordProcessors: [], traceExporter: expect.any(Object) });
|
||||
expect(sdk.options[0]).not.toHaveProperty('metricReader');
|
||||
expect(sdk.options[0]).not.toHaveProperty('logRecordProcessor');
|
||||
expect(sdk.start).toHaveBeenCalledOnce();
|
||||
await instrumentation.shutdownInstrumentation();
|
||||
expect(sdk.shutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -90,6 +90,24 @@ function makeRecoveryActionRow(overrides: Record<string, unknown> = {}) {
|
||||
const LATEST_RUN_TIMES = { startedAt: new Date(), createdAt: new Date() };
|
||||
|
||||
describe("issueRecoveryActionService", () => {
|
||||
it.each(["unrelated unique constraint", "other SQLSTATE", "cyclic cause"])("does not retry %s errors", async kind => {
|
||||
const constraint = "issue_recovery_actions_active_source_uq";
|
||||
const error: Error & { cause?: unknown } = new Error("Wrapped database failure");
|
||||
error.cause = kind === "unrelated unique constraint" ? { code: "23505", constraint_name: "other_unique_constraint" }
|
||||
: kind === "other SQLSTATE" ? { code: "23503", constraint_name: constraint } : error;
|
||||
const query = { from() { return this; }, where() { return this; }, orderBy() { return this; },
|
||||
limit() { return Promise.resolve([]); } };
|
||||
const fakeDb = {
|
||||
async transaction<T>(fn: (tx: unknown) => Promise<T>): Promise<T> { return fn(fakeDb); },
|
||||
select: vi.fn(() => query),
|
||||
insert: vi.fn(() => ({ values: () => ({ returning: async () => { throw error; } }) })),
|
||||
};
|
||||
await expect(issueRecoveryActionService(fakeDb as never).upsertSourceScoped({ companyId: "company", sourceIssueId: "issue",
|
||||
kind: "missing_disposition", cause: "saved-debt", fingerprint: "same-source", nextAction: "Restore existing work." }))
|
||||
.rejects.toBe(error);
|
||||
expect(fakeDb.select).toHaveBeenCalledOnce();
|
||||
expect(fakeDb.insert).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("does not reactivate an action resolved between the active read and update", async () => {
|
||||
const existingRow = makeRecoveryActionRow({ id: "existing-action", attemptCount: 1 });
|
||||
const createdRow = makeRecoveryActionRow({ id: "new-action", attemptCount: 1 });
|
||||
@@ -413,6 +431,49 @@ describeEmbeddedPostgres("issue recovery actions", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("converges two service instances after their real inserts compete for one active source", async () => {
|
||||
const { companyId, managerId, sourceIssueId } = await seedCompany();
|
||||
const input = { companyId, sourceIssueId, kind: "stranded_assigned_issue" as const, ownerType: "agent" as const,
|
||||
ownerAgentId: managerId, cause: "stranded_assigned_issue", fingerprint: "concurrent-source",
|
||||
evidence: { sourceRunId: "concurrent-source-run" }, lastAttemptAt: new Date("2026-11-03T12:00:00Z"),
|
||||
nextAction: "Restore the existing execution path." };
|
||||
let release!: () => void, acquired!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const locked = new Promise<void>(resolve => { acquired = resolve; });
|
||||
const lock = db.transaction(async tx => {
|
||||
await tx.execute(sql`lock table issue_recovery_actions in share mode`);
|
||||
acquired(); await gate;
|
||||
});
|
||||
await locked;
|
||||
const outcomes = Promise.allSettled([issueRecoveryActionService(db).upsertSourceScoped(input),
|
||||
issueRecoveryActionService(db).upsertSourceScoped(input)]);
|
||||
try {
|
||||
await vi.waitFor(async () => {
|
||||
const [waiting] = await db.execute<{ count: number }>(sql`select count(*)::int as count from pg_stat_activity
|
||||
where datname = current_database() and state = 'active' and wait_event_type = 'Lock'
|
||||
and query like 'insert into "issue_recovery_actions"%'`);
|
||||
expect(waiting.count).toBe(2);
|
||||
}, { timeout: 5000, interval: 20 });
|
||||
} finally { release(); await lock; await outcomes; }
|
||||
const results = await outcomes;
|
||||
const active = await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.companyId, companyId),
|
||||
eq(issueRecoveryActions.sourceIssueId, sourceIssueId), sql`${issueRecoveryActions.status} in ('active', 'escalated')`));
|
||||
expect(active).toHaveLength(1);
|
||||
expect(results.flatMap(result => result.status === "rejected" ? [{
|
||||
name: result.reason?.constructor?.name, code: result.reason?.code,
|
||||
causeName: result.reason?.cause?.constructor?.name, causeCode: result.reason?.cause?.code,
|
||||
causeConstraint: result.reason?.cause?.constraint_name,
|
||||
}] : [])).toEqual([]);
|
||||
expect(results.map(result => result.status === "fulfilled" ? result.value.id : null)).toEqual([active[0]!.id, active[0]!.id]);
|
||||
// The lost insert uses the existing upsert merge, just like a second sequential call.
|
||||
expect(active[0]).toMatchObject({
|
||||
status: "active", attemptCount: 2, maxAttempts: null, timeoutAt: null,
|
||||
ownerType: "agent", ownerAgentId: managerId, ownerUserId: null,
|
||||
evidence: input.evidence, lastAttemptAt: input.lastAttemptAt,
|
||||
nextAction: input.nextAction, wakePolicy: null, monitorPolicy: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("upserts one active source-scoped action per issue and keeps company scoping explicit", async () => {
|
||||
const { companyId, managerId, sourceIssueId } = await seedCompany();
|
||||
const svc = issueRecoveryActionService(db);
|
||||
|
||||
@@ -125,3 +125,11 @@ describe("owned maintenance providers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("configured acceptance boundaries", () => {
|
||||
it("rejects remote collectors and fake-clock configuration before opening PostgreSQL", async () => {
|
||||
await expect(startMaintenanceServer("not-a-database-url", { configuration: { telemetryOrigin: "https://telemetry.example.test" } })).rejects.toThrow("loopback");
|
||||
await expect(startMaintenanceServer("not-a-database-url", { clock: true, configuration: { announcements: true } })).rejects.toThrow("real clock");
|
||||
await expect(startMaintenanceServer("not-a-database-url", { configuration: { automaticBackups: { intervalMinutes: 0, idleCheckpoint: true } } })).rejects.toThrow("positive integer");
|
||||
});
|
||||
});
|
||||
@@ -206,7 +206,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await db.update(heartbeatRuns).set({ status: "running", finishedAt: null }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await db.transaction(async tx => {
|
||||
await createWakeAdmissionWriter().coalesceIntoActiveExecutionRun(createAdmissionTransactionScope(f.companyId, tx as unknown as typeof db), {
|
||||
companyId: f.companyId, agentId: f.agentId, activeExecutionRunId: f.runId,
|
||||
companyId: f.companyId, agentId: f.agentId, activeExecutionRunId: f.runId, activeExecutionRunStatus: "running",
|
||||
mergedContextSnapshot: { issueId: f.issueId }, source: "automation", triggerDetail: "system",
|
||||
payload: { issueId: f.issueId }, requestedByActorType: "system", requestedByActorId: null,
|
||||
idempotencyKey: key,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, mkdir, open, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { observeIdleBackupCheckpoint } from './helpers/maintenance/backup-observation.js';
|
||||
|
||||
it('observes complete owned gzip bytes without retaining SQL and refuses missing, truncated, foreign, historical or failed checkpoints', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'sleep-backup-observer-'));
|
||||
const id = '12345678-1234-1234-1234-123456789012';
|
||||
const startedAt = new Date(Date.now() - 1000).toISOString();
|
||||
const backupDir = path.join(directory, 'backups'); await mkdir(backupDir);
|
||||
try {
|
||||
const observe = () => observeIdleBackupCheckpoint(directory, [], startedAt, id);
|
||||
await expect(observe()).rejects.toThrow();
|
||||
await writeFile(path.join(backupDir, '.idle-backup-wake-required'), 'normal owner marker');
|
||||
await expect(observe()).rejects.toThrow('no new archive');
|
||||
const archive = path.join(backupDir, 'owned.sql.gz'), bytes = gzipSync(`COPY fixture;\n${id}\n`);
|
||||
await writeFile(archive, bytes);
|
||||
const receipt = await observe();
|
||||
expect(receipt.archives[0]).toMatchObject({ bytes: bytes.length, gzipVerified: true, fixtureIdentifierFound: true });
|
||||
expect(receipt.archives[0].sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(JSON.stringify(receipt)).not.toContain(id);
|
||||
await expect(observeIdleBackupCheckpoint(directory, ['owned.sql.gz'], startedAt, id)).rejects.toThrow('no new archive');
|
||||
await expect(observeIdleBackupCheckpoint(directory, [], new Date(Date.now() + 1000).toISOString(), id)).rejects.toThrow('predates');
|
||||
await writeFile(archive, bytes.subarray(0, bytes.length - 4)); await expect(observe()).rejects.toThrow();
|
||||
await writeFile(archive, gzipSync('foreign fixture')); await expect(observe()).rejects.toThrow('owned fixture');
|
||||
await writeFile(archive, bytes); await writeFile(path.join(backupDir, 'db-backup-to-s3.failure'), 'failed');
|
||||
await expect(observe()).rejects.toThrow('failure marker');
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it('uses opened-file mtime when birthtime is zero and still rejects old or malformed provenance', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'sleep-backup-no-birthtime-'));
|
||||
const id = '12345678-1234-1234-1234-123456789012', startedAt = new Date(Date.now() - 1000).toISOString();
|
||||
const backupDir = path.join(directory, 'backups'); await mkdir(backupDir);
|
||||
const archive = path.join(backupDir, 'owned.sql.gz');
|
||||
await writeFile(archive, gzipSync(`COPY fixture;\n${id}\n`));
|
||||
await writeFile(path.join(backupDir, '.idle-backup-wake-required'), 'normal owner marker');
|
||||
const handle = await open(archive, 'r'), prototype = Object.getPrototypeOf(handle), originalStat = prototype.stat;
|
||||
await handle.close();
|
||||
let override: { birthtimeMs: number; mtimeMs?: number } = { birthtimeMs: 0 };
|
||||
const spy = vi.spyOn(prototype, 'stat').mockImplementation(async function (this: unknown, ...args: unknown[]) {
|
||||
return Object.assign(await originalStat.apply(this, args), override);
|
||||
});
|
||||
try {
|
||||
const observed = await observeIdleBackupCheckpoint(directory, [], startedAt, id);
|
||||
expect(observed.archives[0]).toMatchObject({ createdAtSource: 'mtime', gzipVerified: true, fixtureIdentifierFound: true });
|
||||
expect(Date.parse(observed.archives[0]!.createdAt)).toBeGreaterThanOrEqual(Date.parse(startedAt));
|
||||
for (const invalid of [
|
||||
{ birthtimeMs: 0, mtimeMs: Date.parse(startedAt) - 1 }, { birthtimeMs: 0, mtimeMs: 0 },
|
||||
{ birthtimeMs: 0, mtimeMs: NaN }, { birthtimeMs: NaN }, { birthtimeMs: -1 },
|
||||
{ birthtimeMs: 0, mtimeMs: Date.now() + 60_000 },
|
||||
{ birthtimeMs: Date.parse(startedAt) - 1, mtimeMs: Date.now() },
|
||||
]) {
|
||||
override = invalid;
|
||||
await expect(observeIdleBackupCheckpoint(directory, [], startedAt, id)).rejects.toThrow('predates');
|
||||
}
|
||||
await expect(observeIdleBackupCheckpoint(directory, [], 'invalid', id)).rejects.toThrow('checkpoint timestamp');
|
||||
} finally { spy.mockRestore(); await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { createHash, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createServer } from 'node:http';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createDb } from '@paperclipai/db';
|
||||
import { resolveExternalTestDatabaseUrl } from './helpers/external-test-database.js';
|
||||
import { configuredSleepFeatures, finalizeLocalSleepReceipt, observeConfiguredSignals, observeIncomingHttpWindow, observedNoIncomingHttp, sleepConfigurationDigest } from '../../../scripts/sleep-qualification/evidence.mjs';
|
||||
import { idleBackupArchiveNames, observeIdleBackupCheckpoint } from './helpers/maintenance/backup-observation.js';
|
||||
import { stabilizeEarlyDiagnostics } from './helpers/maintenance/early-diagnostic-stabilization.js';
|
||||
import { createMaintenanceS3Boundary } from './helpers/maintenance/s3-boundary.js';
|
||||
import { managedSleepConfiguration, managedSleepServerOptions, provisionManagedSleepCompany } from './helpers/maintenance/managed-profile.js';
|
||||
import { prepareMaintenanceRuntime } from './helpers/maintenance/runtime-build.js';
|
||||
import { produceCompletedToolReview } from './helpers/maintenance/configuration-producers.js';
|
||||
import { observeMaintenanceBrowser } from './helpers/maintenance/browser-observation.js';
|
||||
import { startMaintenanceServer, withMaintenanceProvider } from './helpers/maintenance/server-harness.js';
|
||||
import { CLOUD_CONTROL_AUDIENCE, CLOUD_CONTROL_HEADER, CLOUD_CONTROL_JWS_TYPE, CLOUD_RUNTIME_IDENTITY_ISSUER } from '../services/cloud-runtime-identity.js';
|
||||
|
||||
const databaseUrl = resolveExternalTestDatabaseUrl('PAPERCLIP_SLEEP_ACCEPTANCE_TEST_DATABASE_URL');
|
||||
if (process.env.PAPERCLIP_SLEEP_CONFIGURED_ACCEPTANCE === '1' && !databaseUrl) throw new Error('Configured acceptance requires an explicit dedicated PAPERCLIP_SLEEP_ACCEPTANCE_TEST_DATABASE_URL; embedded allocation is forbidden');
|
||||
const cloudSha = process.env.PAPERCLIP_SLEEP_CLOUD_SHA;
|
||||
if (process.env.PAPERCLIP_SLEEP_CONFIGURED_ACCEPTANCE === '1' && !/^[a-f0-9]{40}$/.test(cloudSha ?? '')) throw new Error('Configured acceptance requires the exact PAPERCLIP_SLEEP_CLOUD_SHA');
|
||||
const cloudCheckout = process.env.PAPERCLIP_SLEEP_CLOUD_CHECKOUT;
|
||||
if (process.env.PAPERCLIP_SLEEP_CONFIGURED_ACCEPTANCE === '1' && !cloudCheckout) throw new Error('Configured acceptance requires a reviewed PAPERCLIP_SLEEP_CLOUD_CHECKOUT with its built qualification broker adapter');
|
||||
const enabled = process.env.PAPERCLIP_SLEEP_CONFIGURED_ACCEPTANCE === '1' && Boolean(databaseUrl);
|
||||
const quietMs = 90_000, apiCooldownMs = 600_000;
|
||||
if (enabled && process.env.PAPERCLIP_MAINTENANCE_KEEP_ARTIFACTS !== '1') throw new Error('Configured qualification requires retained raw evidence');
|
||||
|
||||
describe.skipIf(!enabled)('configured assembled sleep qualification (explicit owned external PostgreSQL only)', () => {
|
||||
beforeAll(async () => { await prepareMaintenanceRuntime(); }, 210_000);
|
||||
it('retains normal credentials, completed work and a future routine through a real cooldown and quiet window', async () => {
|
||||
const phase = (name: string) => console.info(JSON.stringify({ qualificationPhase: name, at: new Date().toISOString() }));
|
||||
phase('starting-owned-fixtures');
|
||||
const collectorRequests: Array<{ path: string; body: unknown }> = [];
|
||||
const collector = createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
collectorRequests.push({ path: req.url!, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) });
|
||||
res.setHeader('Content-Type', 'application/json'); res.end('{}');
|
||||
});
|
||||
if (execFileSync('git', ['-C', cloudCheckout!, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() !== cloudSha) throw new Error('Qualification Cloud checkout does not match the exact requested SHA');
|
||||
const { startQualificationBroker } = await import(pathToFileURL(path.join(cloudCheckout!, 'scripts/sleep-qualification-broker.mjs')).href);
|
||||
const broker = await startQualificationBroker();
|
||||
const s3 = createMaintenanceS3Boundary();
|
||||
try {
|
||||
await withMaintenanceProvider(s3.server, async s3Origin => {
|
||||
await withMaintenanceProvider(collector, async origin => {
|
||||
const pair = generateKeyPairSync('ed25519');
|
||||
const jwk = { ...pair.publicKey.export({ format: 'jwk' }), kid: 'sleep-qualification', alg: 'EdDSA', use: 'sig' };
|
||||
const configuration = managedSleepConfiguration(origin, process.env.PAPERCLIP_SLEEP_BROWSER_ACCEPTANCE === '1');
|
||||
// Independent intended logical contract; collector ports are observed separately.
|
||||
const expected = { target: 'owned-local-assembled', appSha: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), cloudSha: cloudSha!,
|
||||
configDigest: createHash('sha256').update(JSON.stringify({ automaticBackups: { intervalMinutes: 60, idleCheckpoint: true }, announcements: true,
|
||||
telemetry: true, otel: { enabled: true, protocol: 'http/json' }, serveUi: process.env.PAPERCLIP_SLEEP_BROWSER_ACCEPTANCE === '1',
|
||||
managedInstance: true, deploymentMode: 'authenticated', storageProvider: 's3' })).digest('hex'),
|
||||
collectorOrigins: { telemetry: `${origin}/telemetry`, otel: origin } };
|
||||
expect(sleepConfigurationDigest(configuration)).toBe(expected.configDigest);
|
||||
const startedAt = new Date().toISOString();
|
||||
const server = await startMaintenanceServer(databaseUrl!, managedSleepServerOptions(configuration, JSON.stringify({ keys: [jwk] }), s3Origin, broker));
|
||||
phase('actual-server-ready');
|
||||
let fixture = createDb(databaseUrl!, { maxConnections: 1 });
|
||||
const control = (action: string, idleCapabilities?: unknown) => {
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = encode({ alg: 'EdDSA', typ: CLOUD_CONTROL_JWS_TYPE, kid: jwk.kid });
|
||||
const payload = encode({ v: 1, iss: CLOUD_RUNTIME_IDENTITY_ISSUER, aud: CLOUD_CONTROL_AUDIENCE, sub: 'maintenance-stack', action, requestId: randomUUID(), iat: now, exp: now + 60, ...(idleCapabilities ? { idleCapabilities } : {}) });
|
||||
return { [CLOUD_CONTROL_HEADER]: `${header}.${payload}.${sign(null, Buffer.from(`${header}.${payload}`, 'ascii'), pair.privateKey).toString('base64url')}` };
|
||||
};
|
||||
let failure: { error: unknown } | undefined;
|
||||
try {
|
||||
await writeFile(path.join(server.directory, 'sleep-managed-startup.json'), JSON.stringify(await server.snapshot(), null, 2));
|
||||
const company = await provisionManagedSleepCompany(server, fixture);
|
||||
expect(await server.api('GET', '/announcements/current')).toMatchObject({ id: 'new-projects' });
|
||||
const announcement = (await server.snapshot()).announcement;
|
||||
expect(announcement?.intercepted).toBe(true);
|
||||
expect(announcement?.requests).toEqual(['https://pages.paperclip.ing/announcements/v1/current.json']);
|
||||
expect(announcement?.unmatched).toEqual([]);
|
||||
const agent = await server.api<{ id: string }>('POST', `/companies/${company.id}/agents`, { name: 'Owned process worker', role: 'general', adapterType: 'process',
|
||||
adapterConfig: { command: process.execPath, args: ['-e', "console.log('sleep-acceptance-useful-work')"], cwd: server.directory }, runtimeConfig: { heartbeat: { enabled: true, intervalSec: 0, wakeOnDemand: true } } });
|
||||
const chatHistory = await server.produceChatHistory();
|
||||
const toolHistory = await produceCompletedToolReview(server, fixture, company.id);
|
||||
const github = await server.prepareGitHub(company.id);
|
||||
await server.api('PATCH', `/tool-connections/${github.connectionId}`, { enabled: true });
|
||||
const githubContinuity = await server.settleGitHub(github);
|
||||
await expect.poll(async () => (await broker.binding())?.bindings.length, { timeout: 30_000 }).toBe(1);
|
||||
await broker.enqueue();
|
||||
await broker.deliver(`http://127.0.0.1:${server.port}`, () => control('connector-events:wake')[CLOUD_CONTROL_HEADER]);
|
||||
// The normally enabled connection owner leases and acknowledges the real sealed event.
|
||||
await expect.poll(async () => {
|
||||
const [delivery] = await fixture.$client`select status from connection_event_deliveries where provider_delivery_id='qualification-delivery'`;
|
||||
return delivery?.status;
|
||||
}, { timeout: 30_000 }).toBe('processed');
|
||||
expect(await broker.pending()).toEqual([]);
|
||||
const brokerBinding = await broker.binding();
|
||||
expect(brokerBinding?.bindings).toHaveLength(1);
|
||||
const key = await server.api<{ id: string; token: string }>('POST', `/agents/${agent.id}/keys`, { name: 'Retained qualification credential' });
|
||||
const issue = await server.api<{ id: string }>('POST', `/companies/${company.id}/issues`, { title: 'Owned completed history', status: 'todo', assigneeAgentId: agent.id });
|
||||
await expect.poll(async () => {
|
||||
const [run] = await fixture.$client`select status from heartbeat_runs where issue_id=${issue.id} order by created_at desc limit 1`;
|
||||
return run?.status;
|
||||
}, { timeout: 30_000 }).toBe('succeeded');
|
||||
await server.api('PATCH', `/issues/${issue.id}`, { status: 'done' });
|
||||
const routine = await server.api<{ id: string }>('POST', `/companies/${company.id}/routines`, { title: 'Harmless future obligation', assigneeAgentId: agent.id });
|
||||
await server.api('POST', `/routines/${routine.id}/triggers`, { kind: 'schedule', cronExpression: (() => { const next = new Date(Date.now() + 24 * 60 * 60_000); return `0 ${next.getUTCHours()} ${next.getUTCDate()} ${next.getUTCMonth() + 1} *`; })(), timezone: 'UTC' });
|
||||
await server.api('GET', '/agents/me', undefined, { Authorization: `Bearer ${key.token}` });
|
||||
const lastAuthenticatedApiAt = performance.now();
|
||||
await writeFile(path.join(server.directory, 'sleep-setup-receipt.json'), JSON.stringify({ ...expected, configuration, company,
|
||||
agentId: agent.id, keyId: key.id, announcement, chatHistory, toolHistory, githubContinuity, brokerBinding,
|
||||
brokerBoundary: broker.boundary, s3: s3.snapshot(), snapshot: await server.snapshot(),
|
||||
boundary: 'Actual managed startup/tenant provisioning/producers; Cloud front door, GitHub HTTP and S3 service are owned simulated transports' }, null, 2));
|
||||
expect(s3.snapshot().errors).toEqual([]);
|
||||
let browser;
|
||||
try {
|
||||
browser = process.env.PAPERCLIP_SLEEP_BROWSER_ACCEPTANCE === '1' ? await observeMaintenanceBrowser(`http://127.0.0.1:${server.port}`, 'Organize your work.', server.tenantHeaders) : undefined;
|
||||
await writeFile(path.join(server.directory, 'sleep-browser-receipt.json'), JSON.stringify({ browser, observedAt: new Date().toISOString() }, null, 2));
|
||||
} catch (error) {
|
||||
await writeFile(path.join(server.directory, 'sleep-browser-error.json'), JSON.stringify({ observedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) }, null, 2));
|
||||
throw error;
|
||||
}
|
||||
// Setup-only owner settling never substitutes for the later full quiet window.
|
||||
await server.settled(500, 0, { managedSetupRecovery: true });
|
||||
const earlyDrain = await server.api<{ ownerId: string }>('POST', '/instance/task-drain', { purpose: 'idle', ttlMs: 240_000 }, control('task-drain:start'));
|
||||
try {
|
||||
const earlyCapabilities = { incomingWake: await broker.capability(earlyDrain.ownerId), deadlineCatalog: 'domain_deadlines_v1' };
|
||||
const earlyRead = () => server.api<any>('GET', `/instance/task-drain?idleSleepSafety=1&idleSleepDiagnostics=1&scheduledWake=domain-deadlines-v1&ownerId=${earlyDrain.ownerId}`, undefined, control('task-drain:read', earlyCapabilities));
|
||||
const early = await stabilizeEarlyDiagnostics(server, { ownerId: earlyDrain.ownerId, apiSucceededAt: lastAuthenticatedApiAt, read: earlyRead,
|
||||
persist: observations => writeFile(path.join(server.directory, 'sleep-early-diagnostic-attempts.json'), JSON.stringify(observations, null, 2)) });
|
||||
await writeFile(path.join(server.directory, 'sleep-early-diagnostics.json'), JSON.stringify(early, null, 2));
|
||||
} finally { await server.api('DELETE', `/instance/task-drain?ownerId=${earlyDrain.ownerId}`, undefined, control('task-drain:stop')); }
|
||||
// Anchor the full cooldown to a genuine successful opaque-key request.
|
||||
await server.api('GET', '/agents/me', undefined, { Authorization: `Bearer ${key.token}` });
|
||||
const lastApiActivity = performance.now();
|
||||
await fixture.$client.end({ timeout: 1 });
|
||||
await server.trace();
|
||||
phase('first-real-ten-minute-api-cooldown');
|
||||
// The real authenticated-use cooldown is part of ordinary behavior.
|
||||
// No fake clock, production timing override, tenant health poll or SQL observer.
|
||||
await delay(apiCooldownMs + 100);
|
||||
await server.settled();
|
||||
const activeSignals = observeConfiguredSignals(collectorRequests);
|
||||
expect(activeSignals.qualificationSpans).toBeGreaterThan(0);
|
||||
expect(activeSignals.telemetryRequests).toBeGreaterThan(0);
|
||||
const before = await server.snapshot(), queries = server.proxy.queries.length, transport = server.proxy.transport;
|
||||
const wallStart = performance.now(), quietStartedAt = new Date().toISOString();
|
||||
phase('first-real-ninety-second-quiet-window');
|
||||
await delay(quietMs);
|
||||
const elapsedMs = performance.now() - wallStart, quietFinishedAt = new Date().toISOString();
|
||||
const after = await server.snapshot(), finalTransport = server.proxy.transport;
|
||||
const quiet = { startedAt: quietStartedAt, finishedAt: quietFinishedAt, elapsedMs, clock: 'real',
|
||||
sqlQueries: server.proxy.queries.length - queries,
|
||||
connectionsOpened: finalTransport.connectionsOpened - transport.connectionsOpened,
|
||||
connectionsRemaining: finalTransport.connectionsOpened - finalTransport.connectionsClosed,
|
||||
nonDatabaseRequests: after.outgoingHttpRequests - before.outgoingHttpRequests,
|
||||
nonDatabaseBytes: after.network.nonDatabaseBytesWritten - before.network.nonDatabaseBytesWritten,
|
||||
observerRequests: observeIncomingHttpWindow(before.incomingHttp, after.incomingHttp) };
|
||||
await writeFile(path.join(server.directory, 'sleep-first-frozen-quiet.json'), JSON.stringify({ quiet, announcementBefore: before.announcement, announcementAfter: after.announcement }, null, 2));
|
||||
const signalObservation = observeConfiguredSignals(collectorRequests);
|
||||
await writeFile(path.join(server.directory, 'sleep-first-signal-observation.json'), JSON.stringify(signalObservation, null, 2));
|
||||
expect(signalObservation.qualificationSpans).toBeGreaterThan(0);
|
||||
expect(signalObservation.telemetryRequests).toBeGreaterThan(0);
|
||||
expect(signalObservation.metricRequests).toBe(0); expect(signalObservation.logRequests).toBe(0);
|
||||
expect(signalObservation.malformedTraceRequests).toBe(0); expect(signalObservation.unexpectedRequests).toBe(0);
|
||||
expect(quiet.elapsedMs).toBeGreaterThanOrEqual(quietMs);
|
||||
expect(quiet.sqlQueries).toBe(0);
|
||||
expect(quiet.connectionsOpened).toBe(0);
|
||||
expect(quiet.connectionsRemaining).toBe(0);
|
||||
expect(quiet.nonDatabaseRequests).toBe(0);
|
||||
expect(quiet.nonDatabaseBytes).toBe(0);
|
||||
// The installed listener must have observed the actual setup HTTP traffic.
|
||||
expect(quiet.observerRequests.before.requests).toBeGreaterThan(0);
|
||||
expect(observedNoIncomingHttp(quiet.observerRequests, quiet)).toBe(true);
|
||||
expect(after.announcement).toEqual(before.announcement);
|
||||
const backupNamesBefore = await idleBackupArchiveNames(server.directory);
|
||||
const checkpointStartedAt = new Date().toISOString();
|
||||
const drain = await server.api<{ ownerId: string }>('POST', '/instance/task-drain', { purpose: 'idle', ttlMs: 240_000 }, control('task-drain:start'));
|
||||
const capabilities = { incomingWake: await broker.capability(drain.ownerId), deadlineCatalog: 'domain_deadlines_v1' };
|
||||
const read = () => server.api<any>('GET', `/instance/task-drain?idleSleepSafety=1&scheduledWake=domain-deadlines-v1&ownerId=${drain.ownerId}`, undefined, control('task-drain:read', capabilities));
|
||||
const initial = await read();
|
||||
await writeFile(path.join(server.directory, 'sleep-initial-safety.json'), JSON.stringify(initial, null, 2));
|
||||
const final = await read();
|
||||
phase('first-owned-eligibility-reports-read');
|
||||
// Persist raw refusal evidence before any assertion can terminate the case.
|
||||
await writeFile(path.join(server.directory, 'sleep-initial-final-safety.json'), JSON.stringify({ initial, final }, null, 2));
|
||||
if (initial.idleSleepSafety.backgroundWork !== 'none' || final.idleSleepSafety.backgroundWork !== 'none') {
|
||||
const diagnostic = await server.api<any>('GET', `/instance/task-drain?idleSleepSafety=1&idleSleepDiagnostics=1&scheduledWake=domain-deadlines-v1&ownerId=${drain.ownerId}`, undefined, control('task-drain:read', capabilities));
|
||||
await writeFile(path.join(server.directory, 'sleep-refusal-diagnostics.json'), JSON.stringify(diagnostic, null, 2));
|
||||
}
|
||||
const { parseApiWakeAdmission, authorizeApiWakeAdmission } = await import('../services/idle-api-wake-admission.js');
|
||||
const assertIncomingProof = (report: any, incomingWake: any, ownerId: string) => {
|
||||
expect(report.idleSleepSafety.incomingWake).toEqual(incomingWake);
|
||||
const admission = parseApiWakeAdmission(report.idleSleepSafety.apiWakeAdmission);
|
||||
expect(admission).toMatchObject({ stackId: 'maintenance-stack', ownerId,
|
||||
capabilityGeneration: incomingWake.generation, challenge: incomingWake.apiKeyProof.challenge });
|
||||
expect(authorizeApiWakeAdmission(admission, { stackId: 'maintenance-stack', token: key.token, now: Date.now() })).toBeDefined();
|
||||
};
|
||||
assertIncomingProof(initial, capabilities.incomingWake, drain.ownerId);
|
||||
assertIncomingProof(final, capabilities.incomingWake, drain.ownerId);
|
||||
expect(final.idleSleepSafety.apiWakeAdmission).toEqual(initial.idleSleepSafety.apiWakeAdmission);
|
||||
const proof = final.idleSleepSafety;
|
||||
const backupCheckpoint = await observeIdleBackupCheckpoint(server.directory, backupNamesBefore, checkpointStartedAt, company.id);
|
||||
const observations = { version: 1, ...expected, startedAt,
|
||||
configuration, browser, s3: s3.snapshot(), announcement: after.announcement, chatHistory, toolHistory, githubContinuity, brokerBinding, brokerBoundary: broker.boundary, backupCheckpoint,
|
||||
signalObservation, features: configuredSleepFeatures({ announcement: after.announcement, signalObservation, backupCheckpoint }),
|
||||
quiet,
|
||||
eligibility: { observedAt: new Date().toISOString(), backgroundWork: proof.backgroundWork, ownedHold: final.ownerId === drain.ownerId, apiCooldownElapsedMs: performance.now() - lastApiActivity,
|
||||
unchangedGeneration: /^[a-f0-9]{64}$/.test(proof.deadlineProof?.generation ?? '') && initial.idleSleepSafety.deadlineProof?.generation === proof.deadlineProof?.generation }, initial, final,
|
||||
live: { databaseSuspension: 'INCONCLUSIVE', hostSuspension: 'INCONCLUSIVE', backupPersistence: 'INCONCLUSIVE', reason: 'Local actual server with simulated Cloud signer; no deployed provider target authorized' },
|
||||
coverage: { announcements: 'Actual route, validated feed/cache and UI with one named injected provider transport fetch; DNS/HTTPS guard covered separately, not exercised here.', browser: browser ? 'Actual Chromium foreground/background measured, then closed before server quiet window' : 'Closed; set PAPERCLIP_SLEEP_BROWSER_ACCEPTANCE=1 with built UI and headed Chromium for background/active evidence', broker: 'Retained enabled connection, actual encrypted OAuth vault, actual continuity/binding owners and signed/sealed production broker delivery; host capability from actual ConnectorStore', history: 'Normal task/credential/routine, actual tool review rejection/outbox retirement, actual signed chat ingress completion and endpoint archival' } };
|
||||
await writeFile(path.join(server.directory, 'sleep-configuration-observations.json'), JSON.stringify(observations, null, 2));
|
||||
const { receipt, report } = finalizeLocalSleepReceipt(observations, expected);
|
||||
await writeFile(path.join(server.directory, 'sleep-configuration-receipt.json'), JSON.stringify(receipt, null, 2));
|
||||
await writeFile(path.join(server.directory, 'sleep-configuration-evaluation.json'), JSON.stringify(report, null, 2));
|
||||
expect(proof.version).toBe(3); expect(proof.backgroundWork).toBe('none');
|
||||
expect(proof.deadlineProof).toMatchObject({ catalog: 'domain_deadlines_v1', generation: expect.stringMatching(/^[a-f0-9]{64}$/) });
|
||||
fixture = createDb(databaseUrl!, { maxConnections: 1 });
|
||||
const [retained] = await fixture.$client`select (select count(*)::int from agent_api_keys where id=${key.id}) as keys, (select count(*)::int from issues where id=${issue.id}) as issues, (select count(*)::int from routines where id=${routine.id}) as routines, (select enabled from tool_connections where id=${github.connectionId}) as github_enabled`;
|
||||
expect(retained).toEqual({ keys: 1, issues: 1, routines: 1, github_enabled: true });
|
||||
await server.api('DELETE', `/instance/task-drain?ownerId=${drain.ownerId}`, undefined, control('task-drain:stop'));
|
||||
const nextIssue = await server.api<{ id: string }>('POST', `/companies/${company.id}/issues`, { title: 'First ordinary producer after quiet', status: 'todo', assigneeAgentId: agent.id }, { Authorization: `Bearer ${key.token}` });
|
||||
const reactivationApiAt = performance.now();
|
||||
await expect.poll(async () => {
|
||||
const [run] = await fixture.$client`select status from heartbeat_runs where issue_id=${nextIssue.id} order by created_at desc limit 1`;
|
||||
return run?.status;
|
||||
}, { timeout: 30_000 }).toBe('succeeded');
|
||||
await server.api('PATCH', `/issues/${nextIssue.id}`, { status: 'done' });
|
||||
const [executions] = await fixture.$client`select count(*)::int as count from heartbeat_runs where issue_id=${nextIssue.id}`;
|
||||
expect(executions.count).toBe(1);
|
||||
await fixture.$client.end({ timeout: 1 });
|
||||
phase('reactivation-real-ten-minute-api-cooldown');
|
||||
await delay(apiCooldownMs + 100);
|
||||
await server.settled();
|
||||
const resumed = await server.snapshot(), resumedQueries = server.proxy.queries.length, resumedTransport = server.proxy.transport;
|
||||
const resumedStart = performance.now(), resumedStartedAt = new Date().toISOString();
|
||||
phase('reactivation-real-ninety-second-quiet-window');
|
||||
await delay(quietMs);
|
||||
const resumedElapsedMs = performance.now() - resumedStart, resumedFinishedAt = new Date().toISOString();
|
||||
const resumedEnd = await server.snapshot(), resumedFinalTransport = server.proxy.transport;
|
||||
// Freeze every measurement before signed drain/proof requests do SQL.
|
||||
const resumedQuiet = { startedAt: resumedStartedAt, finishedAt: resumedFinishedAt, elapsedMs: resumedElapsedMs, clock: 'real',
|
||||
sqlQueries: server.proxy.queries.length - resumedQueries,
|
||||
connectionsOpened: resumedFinalTransport.connectionsOpened - resumedTransport.connectionsOpened,
|
||||
connectionsRemaining: resumedFinalTransport.connectionsOpened - resumedFinalTransport.connectionsClosed,
|
||||
nonDatabaseRequests: resumedEnd.outgoingHttpRequests - resumed.outgoingHttpRequests,
|
||||
nonDatabaseBytes: resumedEnd.network.nonDatabaseBytesWritten - resumed.network.nonDatabaseBytesWritten,
|
||||
observerRequests: observeIncomingHttpWindow(resumed.incomingHttp, resumedEnd.incomingHttp) };
|
||||
await writeFile(path.join(server.directory, 'sleep-resumed-frozen-quiet.json'), JSON.stringify({ quiet: resumedQuiet, announcementBefore: resumed.announcement, announcementAfter: resumedEnd.announcement }, null, 2));
|
||||
const resumedSignals = observeConfiguredSignals(collectorRequests);
|
||||
await writeFile(path.join(server.directory, 'sleep-resumed-signal-observation.json'), JSON.stringify(resumedSignals, null, 2));
|
||||
expect(resumedSignals.qualificationSpans).toBeGreaterThan(0);
|
||||
expect(resumedSignals.telemetryRequests).toBeGreaterThan(0);
|
||||
expect(resumedSignals.metricRequests).toBe(0); expect(resumedSignals.logRequests).toBe(0);
|
||||
expect(resumedSignals.malformedTraceRequests).toBe(0); expect(resumedSignals.unexpectedRequests).toBe(0);
|
||||
expect(resumedQuiet.elapsedMs).toBeGreaterThanOrEqual(quietMs);
|
||||
expect(resumedQuiet.sqlQueries).toBe(0);
|
||||
expect(resumedQuiet.connectionsOpened).toBe(0);
|
||||
expect(resumedQuiet.connectionsRemaining).toBe(0);
|
||||
expect(resumedQuiet.nonDatabaseRequests).toBe(0);
|
||||
expect(resumedQuiet.nonDatabaseBytes).toBe(0);
|
||||
expect(resumedQuiet.observerRequests.before.requests).toBeGreaterThan(0);
|
||||
expect(observedNoIncomingHttp(resumedQuiet.observerRequests, resumedQuiet)).toBe(true);
|
||||
expect(resumedEnd.announcement).toEqual(resumed.announcement);
|
||||
expect(resumedEnd.announcement?.unmatched).toEqual([]);
|
||||
const resumedDrain = await server.api<{ ownerId: string }>('POST', '/instance/task-drain', { purpose: 'idle', ttlMs: 240_000 }, control('task-drain:start'));
|
||||
const resumedCapabilities = { incomingWake: await broker.capability(resumedDrain.ownerId), deadlineCatalog: 'domain_deadlines_v1' };
|
||||
const readResumed = () => server.api<any>('GET', `/instance/task-drain?idleSleepSafety=1&scheduledWake=domain-deadlines-v1&ownerId=${resumedDrain.ownerId}`, undefined, control('task-drain:read', resumedCapabilities));
|
||||
const resumedInitial = await readResumed();
|
||||
await writeFile(path.join(server.directory, 'sleep-resumed-initial-safety.json'), JSON.stringify(resumedInitial, null, 2));
|
||||
const resumedProof = await readResumed();
|
||||
await writeFile(path.join(server.directory, 'sleep-resumed-initial-final-safety.json'), JSON.stringify({ initial: resumedInitial, final: resumedProof }, null, 2));
|
||||
if (resumedInitial.idleSleepSafety.backgroundWork !== 'none' || resumedProof.idleSleepSafety.backgroundWork !== 'none') {
|
||||
const diagnostic = await server.api<any>('GET', `/instance/task-drain?idleSleepSafety=1&idleSleepDiagnostics=1&scheduledWake=domain-deadlines-v1&ownerId=${resumedDrain.ownerId}`, undefined, control('task-drain:read', resumedCapabilities));
|
||||
await writeFile(path.join(server.directory, 'sleep-resumed-refusal-diagnostics.json'), JSON.stringify(diagnostic, null, 2));
|
||||
}
|
||||
assertIncomingProof(resumedInitial, resumedCapabilities.incomingWake, resumedDrain.ownerId);
|
||||
assertIncomingProof(resumedProof, resumedCapabilities.incomingWake, resumedDrain.ownerId);
|
||||
expect(resumedProof.idleSleepSafety.apiWakeAdmission).toEqual(resumedInitial.idleSleepSafety.apiWakeAdmission);
|
||||
expect(resumedProof.idleSleepSafety.deadlineProof?.generation).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(resumedProof.idleSleepSafety.deadlineProof?.generation).toBe(resumedInitial.idleSleepSafety.deadlineProof?.generation);
|
||||
expect(resumedProof.idleSleepSafety.backgroundWork).toBe('none');
|
||||
await writeFile(path.join(server.directory, 'sleep-local-reactivation-receipt.json'), JSON.stringify({
|
||||
version: 1, actualProducer: 'POST company issues with process assignee using the retained opaque key after owned drain release', executions: executions.count,
|
||||
realApiCooldownElapsedMs: performance.now() - reactivationApiAt,
|
||||
quiet: resumedQuiet,
|
||||
signalObservation: resumedSignals,
|
||||
realElapsedMs: resumedQuiet.elapsedMs, sqlQueries: resumedQuiet.sqlQueries,
|
||||
networkRequests: resumedQuiet.nonDatabaseRequests,
|
||||
initial: resumedInitial,
|
||||
eligibility: resumedProof.idleSleepSafety,
|
||||
boundary: 'Actual local application reactivation, real cooldown and fresh eligibility; no provider suspension or deployed inbound-wake claim',
|
||||
}, null, 2));
|
||||
const s3Evidence = s3.snapshot();
|
||||
await writeFile(path.join(server.directory, 'sleep-s3-boundary.json'), JSON.stringify(s3Evidence, null, 2));
|
||||
expect(s3Evidence.errors).toEqual([]);
|
||||
phase('configured-qualification-receipts-complete');
|
||||
} catch (error) { failure = { error }; throw error; }
|
||||
finally {
|
||||
const errors: unknown[] = [];
|
||||
try { await writeFile(path.join(server.directory, 'sleep-s3-before-cleanup.json'), JSON.stringify(s3.snapshot(), null, 2)); } catch (error) { errors.push(error); }
|
||||
try { await server.stop(); } catch (error) { errors.push(error); }
|
||||
try { await fixture.$client.end(); } catch (error) { errors.push(error); }
|
||||
// Shutdown can flush storage; capture and validate only after child exit.
|
||||
try { await writeFile(path.join(server.directory, 'sleep-s3-after-cleanup.json'), JSON.stringify(s3.snapshot(), null, 2)); } catch (error) { errors.push(error); }
|
||||
if (s3.snapshot().errors.length) errors.push(new Error(`S3 fixture boundary failed: ${JSON.stringify(s3.snapshot().errors)}`));
|
||||
if (errors.length) throw new AggregateError([...(failure ? [failure.error] : []), ...errors], 'Configured work or owned cleanup failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
} finally { await broker.stop(); }
|
||||
}, 1_800_000);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { assertEarlySleepDiagnostics } from './helpers/maintenance/early-sleep-diagnostics.js';
|
||||
const fixture = () => ({ idleSleepSafety: { diagnostics: { complete: true, checks: [
|
||||
{ reason: 'local.registered_probes', state: 'present' }, { reason: 'local.inspection', state: 'present' },
|
||||
{ reason: 'backup.checkpoint', state: 'not_checked' }, ...['inspection.hold', 'inspection.generation', 'durable.snapshot', 'deadlines.catalog', 'issues'].map(reason => ({ reason, state: 'none' })),
|
||||
] } } });
|
||||
it('retains only explicit early cooldown/diagnostic-backup reasons and refuses every unexpected or unknown blocker', () => {
|
||||
expect(() => assertEarlySleepDiagnostics(fixture(), 90_000)).not.toThrow();
|
||||
for (const [reason, state] of [['issues', 'present'], ['deadlines.catalog', 'unknown'], ['inspection.hold', 'not_checked'], ['local.registered_probes', 'unknown']]) {
|
||||
const f = fixture(); f.idleSleepSafety.diagnostics.checks.find(check => check.reason === reason)!.state = state;
|
||||
expect(() => assertEarlySleepDiagnostics(f, 90_000)).toThrow('Unexpected');
|
||||
}
|
||||
const missing = fixture(); missing.idleSleepSafety.diagnostics.checks.shift(); expect(() => assertEarlySleepDiagnostics(missing, 90_000)).toThrow('Missing');
|
||||
for (const duration of [NaN, Infinity, -1, 600_000]) expect(() => assertEarlySleepDiagnostics(fixture(), duration)).toThrow('cooldown');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createMaintenanceIdleWorkCapture as createCapture, createSettledWorkSamples } from './helpers/maintenance/idle-work-capture.js';
|
||||
|
||||
const captures: ReturnType<typeof createCapture>[] = [];
|
||||
function createMaintenanceIdleWorkCapture(...args: Parameters<typeof createCapture>) {
|
||||
const capture = createCapture(...args); captures.push(capture); return capture;
|
||||
}
|
||||
afterEach(() => { for (const capture of captures.splice(0)) capture.dispose(); });
|
||||
|
||||
const stack = () => 'Error\n at begin (/checkout/server/src/services/owned-work.ts:12:3)\n at owner (/checkout/server/src/index.ts:100:5)';
|
||||
function original() {
|
||||
let active = 0, generation = 0, finishes = 0, tracked: Promise<unknown> | undefined;
|
||||
const beginIdleTrackedWork = () => {
|
||||
active++; generation++; let done = false;
|
||||
return () => { finishes++; if (!done) { done = true; active--; generation++; } };
|
||||
};
|
||||
const trackIdleWork = <T>(work: Promise<T>) => {
|
||||
const finish = beginIdleTrackedWork(); const result = work.finally(finish); tracked = result; return result;
|
||||
};
|
||||
return { beginIdleTrackedWork, trackIdleWork, idleWorkSnapshot: () => ({ active, generation }),
|
||||
finishes: () => finishes, lastTracked: () => tracked };
|
||||
}
|
||||
it('delegates original finish calls/idempotence and returns the exact original tracked promise and rejection', async () => {
|
||||
const actual = original(), capture = createMaintenanceIdleWorkCapture(actual, { stack });
|
||||
const finish = capture.beginIdleTrackedWork(); expect(actual.idleWorkSnapshot().active).toBe(1);
|
||||
finish(); finish(); expect(actual.finishes()).toBe(2); expect(actual.idleWorkSnapshot().active).toBe(0);
|
||||
let resolve!: (value: string) => void;
|
||||
const tracked = capture.trackIdleWork(new Promise<string>(done => { resolve = done; }));
|
||||
expect(tracked).toBe(actual.lastTracked()); expect(capture.snapshot().observedActive).toBe(1);
|
||||
resolve('same'); await expect(tracked).resolves.toBe('same'); expect(capture.snapshot().observedActive).toBe(0);
|
||||
const failure = new Error('original rejection'), rejected = capture.trackIdleWork(Promise.reject(failure));
|
||||
expect(rejected).toBe(actual.lastTracked()); await expect(rejected).rejects.toBe(failure);
|
||||
expect(capture.snapshot().observedActive).toBe(0); expect(actual.idleWorkSnapshot().active).toBe(0);
|
||||
capture.dispose(); capture.dispose(); expect(capture.snapshot().disposed).toBe(true);
|
||||
await expect(capture.trackIdleWork(Promise.resolve('after-dispose'))).resolves.toBe('after-dispose');
|
||||
expect(actual.idleWorkSnapshot().active).toBe(0); expect(capture.snapshot().observedActive).toBe(0);
|
||||
});
|
||||
it('cannot throw from passive metadata into actual work and makes missing observation explicit', () => {
|
||||
const actual = original(), capture = createMaintenanceIdleWorkCapture(actual, { stack, now: () => { throw new Error('private observer detail'); } });
|
||||
let finish!: () => void; expect(() => { finish = capture.beginIdleTrackedWork(); }).not.toThrow();
|
||||
expect(actual.idleWorkSnapshot().active).toBe(1); expect(capture.snapshot().missingPaths).toBeGreaterThan(0);
|
||||
expect(() => finish()).not.toThrow(); expect(actual.idleWorkSnapshot().active).toBe(0);
|
||||
expect(capture.snapshot().observerErrors).toBeGreaterThan(0); expect(JSON.stringify(capture.summary())).not.toContain('private observer detail');
|
||||
});
|
||||
it('bounds active entries, completion tail and filtered callsites without clearing original work', () => {
|
||||
const actual = original();
|
||||
const callers = () => 'private outside value\n' + Array.from({ length: 8 }, (_, i) => ` at work (/root/server/src/services/owned-${i}.ts:1:2)`).join('\n');
|
||||
const capture = createMaintenanceIdleWorkCapture(actual, { stack: callers });
|
||||
const finish = Array.from({ length: 513 }, () => capture.beginIdleTrackedWork());
|
||||
const busy = capture.snapshot(); expect(busy.active).toHaveLength(512); expect(busy.overflow).toBe(1);
|
||||
expect(busy.newSources[0]!.callers).toHaveLength(4); expect(JSON.stringify(busy)).not.toContain('private outside value');
|
||||
expect(actual.idleWorkSnapshot().active).toBe(513); expect(busy.missingPaths).toBeGreaterThan(0);
|
||||
finish.forEach(done => done()); expect(actual.idleWorkSnapshot().active).toBe(0); expect(capture.snapshot().completionTail).toHaveLength(64);
|
||||
});
|
||||
it('bounds unique source metadata and preserves original finisher failures', () => {
|
||||
const actual = original(); let source = 0;
|
||||
const capture = createMaintenanceIdleWorkCapture(actual, { stack: () => `Error\n at owner (/checkout/server/src/services/source-${source++}.ts:1:1)` });
|
||||
for (let i = 0; i < 513; i++) capture.beginIdleTrackedWork()();
|
||||
const state = capture.snapshot(); expect(capture.summary().sourceCatalog).toHaveLength(512); expect(state.sourceOverflow).toBe(1);
|
||||
expect(actual.idleWorkSnapshot().active).toBe(0);
|
||||
const failure = new Error('original finisher'), begin = actual.beginIdleTrackedWork;
|
||||
actual.beginIdleTrackedWork = () => { const finish = begin(); return () => { finish(); throw failure; }; };
|
||||
const observed = createMaintenanceIdleWorkCapture(actual, { stack }); const finish = observed.beginIdleTrackedWork();
|
||||
expect(() => finish()).toThrow(failure); expect(actual.idleWorkSnapshot().active).toBe(0); expect(observed.snapshot().observedActive).toBe(0);
|
||||
});
|
||||
it('bounds settle samples and stores callsites once, while missing paths forbid diagnostic completion', () => {
|
||||
const actual = original(), capture = createMaintenanceIdleWorkCapture(actual, { stack }); const finish = capture.beginIdleTrackedWork();
|
||||
const state = capture.snapshot(), catalog = state.newSources;
|
||||
const samples = createSettledWorkSamples(() => catalog);
|
||||
for (let i = 0; i < 2442; i++) samples.sample({ now: 1000 + i, idle: actual.idleWorkSnapshot(), workCapture: state }, 20 + i, 0);
|
||||
const report = samples.report(); expect(report.samples).toHaveLength(2441); expect(report.sampleLimit).toBe(2441); expect(report.overflowSamples).toBe(1);
|
||||
expect(report.samples[1]!.queryDelta).toBe(1); expect(report.sourceCatalog).toHaveLength(1);
|
||||
expect(JSON.stringify(report.samples)).not.toContain('owned-work'); expect(() => samples.assertComplete()).toThrow('incomplete');
|
||||
finish();
|
||||
const missing = createSettledWorkSamples(() => []); missing.sample({ now: 0, idle: actual.idleWorkSnapshot() }, 0, 0);
|
||||
expect(() => missing.assertComplete()).toThrow('incomplete');
|
||||
});
|
||||
it('intercepts the actual Node24 TS module and preserves every other original export and existing state', async () => {
|
||||
const result = await promisify(execFile)(process.execPath, ['--experimental-test-module-mocks', '--import', fileURLToPath(new URL('../../node_modules/tsx/dist/loader.mjs', import.meta.url)),
|
||||
fileURLToPath(new URL('./helpers/maintenance/idle-work-capture-probe.ts', import.meta.url))], { timeout: 15_000, maxBuffer: 128 * 1024 });
|
||||
expect(JSON.parse(result.stdout.trim())).toMatchObject({ identity: true, originalStatePreserved: true, finalActive: 0 });
|
||||
});
|
||||
it('preserves actual Node unhandledRejection with the same original tracked promise and reason', async () => {
|
||||
const result = await promisify(execFile)(process.execPath, ['--experimental-test-module-mocks', '--import', fileURLToPath(new URL('../../node_modules/tsx/dist/loader.mjs', import.meta.url)),
|
||||
fileURLToPath(new URL('./helpers/maintenance/idle-work-capture-probe.ts', import.meta.url)), 'unhandled'], { timeout: 15_000, maxBuffer: 128 * 1024 });
|
||||
expect(JSON.parse(result.stdout.trim())).toMatchObject({ unhandledOriginalPromise: true, unhandledOriginalReason: true, finalActive: 0 });
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createServer, request } from "node:http";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { expect, it } from "vitest";
|
||||
import { observeIncomingHttpWindow, observedNoIncomingHttp } from "../../../scripts/sleep-qualification/evidence.mjs";
|
||||
import { observeMaintenanceIncomingHttp } from "./helpers/maintenance/incoming-http-observation.js";
|
||||
import { withMaintenanceProvider } from "./helpers/maintenance/server-harness.js";
|
||||
|
||||
it("counts a direct SQL-free health fetch without retaining credentials or changing its response", async () => {
|
||||
// This fixture has no database or outgoing client; its only work is answering HTTP.
|
||||
const server = createServer((_req, res) => { res.setHeader("Content-Type", "application/json"); res.end('{"status":"ok"}'); });
|
||||
server.on("upgrade", (_req, socket) => socket.destroy());
|
||||
const capture = observeMaintenanceIncomingHttp(server);
|
||||
try {
|
||||
await withMaintenanceProvider(server, async origin => {
|
||||
const before = capture.snapshot(), startedAt = new Date().toISOString();
|
||||
expect(before).toMatchObject({ installed: true, valid: true, requests: 0 });
|
||||
expect(observedNoIncomingHttp(observeIncomingHttpWindow(before, before), { startedAt: before.observedAt, finishedAt: before.observedAt })).toBe(true);
|
||||
const response = await fetch(`${origin}/api/health?fixture=private-query`, { signal: AbortSignal.timeout(5_000), headers: { Authorization: "Bearer fixture-private-token" } });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ status: "ok" });
|
||||
const finishedAt = new Date().toISOString(), after = capture.snapshot();
|
||||
expect(after).toMatchObject({ installed: true, valid: true, captureId: before.captureId });
|
||||
const observed = observeIncomingHttpWindow(before, after);
|
||||
expect(observed.requests).toBe(1);
|
||||
expect(observedNoIncomingHttp(observed, { startedAt, finishedAt })).toBe(false);
|
||||
expect(JSON.stringify(observed)).not.toMatch(/private-query|fixture-private-token|Authorization|\/api\/health/);
|
||||
});
|
||||
} finally { capture.dispose(); }
|
||||
});
|
||||
|
||||
it("observes a genuinely request-free interval without making snapshot reads into HTTP", async () => {
|
||||
const server = createServer((_req, res) => res.end());
|
||||
server.on("upgrade", (_req, socket) => socket.destroy());
|
||||
const capture = observeMaintenanceIncomingHttp(server);
|
||||
try {
|
||||
await withMaintenanceProvider(server, async () => {
|
||||
const before = capture.snapshot(), startedAt = new Date().toISOString();
|
||||
capture.snapshot(); await setImmediate(); capture.snapshot();
|
||||
const finishedAt = new Date().toISOString(), after = capture.snapshot();
|
||||
expect(observeIncomingHttpWindow(before, after).requests).toBe(0);
|
||||
expect(observedNoIncomingHttp(observeIncomingHttpWindow(before, after), { startedAt, finishedAt })).toBe(true);
|
||||
// This short boundary test does not claim the configured case's 90-second quiet proof.
|
||||
});
|
||||
} finally { capture.dispose(); }
|
||||
});
|
||||
|
||||
it("counts an actual HTTP upgrade as incoming activity", async () => {
|
||||
const server = createServer((_req, res) => res.end());
|
||||
const upgrade = (_req: unknown, socket: import("node:stream").Duplex) => {
|
||||
socket.end("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n");
|
||||
};
|
||||
server.on("upgrade", upgrade);
|
||||
const capture = observeMaintenanceIncomingHttp(server);
|
||||
try {
|
||||
await withMaintenanceProvider(server, async origin => {
|
||||
const before = capture.snapshot();
|
||||
const client = request(origin, { signal: AbortSignal.timeout(5_000), headers: { Connection: "Upgrade", Upgrade: "fixture" } });
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.once("error", reject);
|
||||
client.once("upgrade", (_response, socket) => { socket.destroy(); resolve(); });
|
||||
client.end();
|
||||
});
|
||||
} finally { client.destroy(); }
|
||||
expect(observeIncomingHttpWindow(before, capture.snapshot()).requests).toBe(1);
|
||||
});
|
||||
} finally { capture.dispose(); server.off("upgrade", upgrade); }
|
||||
});
|
||||
|
||||
it("invalidates a detached listener and disposes only its own listeners", async () => {
|
||||
const app = (_req: unknown, res: import("node:http").ServerResponse) => res.end("app");
|
||||
const server = createServer(app);
|
||||
const unrelatedUpgrade = () => {};
|
||||
server.on("upgrade", unrelatedUpgrade);
|
||||
const originalRequest = server.listeners("request"), originalUpgrade = server.listeners("upgrade");
|
||||
const capture = observeMaintenanceIncomingHttp(server);
|
||||
try {
|
||||
await withMaintenanceProvider(server, async origin => {
|
||||
const counter = server.listeners("request")[0]! as (...args: unknown[]) => void;
|
||||
server.off("request", counter);
|
||||
expect(capture.snapshot()).toMatchObject({ installed: true, valid: false });
|
||||
// Reattaching cannot erase an unobserved interval.
|
||||
server.prependListener("request", counter);
|
||||
expect(capture.snapshot().valid).toBe(false);
|
||||
capture.dispose(); capture.dispose();
|
||||
expect(capture.snapshot()).toMatchObject({ installed: false, valid: false });
|
||||
expect(server.listeners("request")).toEqual(originalRequest);
|
||||
expect(server.listeners("upgrade")).toEqual(originalUpgrade);
|
||||
expect(await (await fetch(origin, { signal: AbortSignal.timeout(5_000) })).text()).toBe("app");
|
||||
});
|
||||
} finally { capture.dispose(); }
|
||||
});
|
||||
|
||||
it("leaves Node's unsupported-upgrade behavior alone when the app has no upgrade handler", () => {
|
||||
const server = createServer((_req, res) => res.end());
|
||||
const requestHandlers = server.listeners("request"), upgradeHandlers = server.listeners("upgrade");
|
||||
expect(() => observeMaintenanceIncomingHttp(server)).toThrow("requires existing app request and upgrade handlers");
|
||||
expect(server.listeners("request")).toEqual(requestHandlers);
|
||||
expect(server.listeners("upgrade")).toEqual(upgradeHandlers);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { createMaintenanceS3Boundary, decodeMaintenanceAwsChunks } from './helpers/maintenance/s3-boundary.js';
|
||||
import { withMaintenanceProvider } from './helpers/maintenance/server-harness.js';
|
||||
import { createS3StorageProvider } from '../storage/s3-provider.js';
|
||||
import { classifyEarlyDiagnostic, stabilizeEarlyDiagnostics } from './helpers/maintenance/early-diagnostic-stabilization.js';
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
it('uses real small-object SDK HTTP with conditional bytes, reads and deletion; unknown operations fail closed', async () => {
|
||||
vi.stubEnv('AWS_ACCESS_KEY_ID', 'owned-fixture'); vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'owned-fixture'); vi.stubEnv('AWS_SESSION_TOKEN', undefined);
|
||||
const fixture = createMaintenanceS3Boundary();
|
||||
await withMaintenanceProvider(fixture.server, async endpoint => {
|
||||
const provider = createS3StorageProvider({ bucket: 'maintenance-test', region: 'us-east-1', endpoint, forcePathStyle: true });
|
||||
const input = { objectKey: 'owned/value', contentType: 'text/plain', contentLength: 3, body: Buffer.from('one') };
|
||||
const first = await provider.putObjectConditional!({ ...input, ifNoneMatch: '*' });
|
||||
expect(first.etag).toBeTruthy();
|
||||
await expect(provider.putObjectConditional!({ ...input, ifNoneMatch: '*' })).rejects.toMatchObject({ name: 'PreconditionFailed' });
|
||||
await expect(provider.putObjectConditional!({ ...input, ifMatch: 'wrong' })).rejects.toMatchObject({ name: 'PreconditionFailed' });
|
||||
await provider.putObjectConditional!({ ...input, body: Buffer.from('two'), ifMatch: first.etag });
|
||||
const object = await provider.getObject({ objectKey: input.objectKey }); const chunks: Buffer[] = [];
|
||||
for await (const chunk of object.stream) chunks.push(Buffer.from(chunk));
|
||||
expect(Buffer.concat(chunks).toString()).toBe('two'); expect((await provider.headObject({ objectKey: input.objectKey })).exists).toBe(true);
|
||||
expect((await provider.listObjects!({ prefix: 'owned/' })).keys).toEqual([input.objectKey]);
|
||||
await provider.deleteObject({ objectKey: input.objectKey }); expect((await provider.headObject({ objectKey: input.objectKey })).exists).toBe(false);
|
||||
expect(fixture.snapshot().errors).toEqual([]);
|
||||
const unknown = await fetch(`${endpoint}/maintenance-test/key?uploads`, { method: 'POST' }); expect(unknown.status).toBe(501);
|
||||
expect(fixture.snapshot().errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
it('decodes only complete bounded AWS checksum chunks and refuses malformed framing', () => {
|
||||
expect(decodeMaintenanceAwsChunks(Buffer.from('3\r\none\r\n3\r\ntwo\r\n0\r\nx-amz-checksum-crc32:YWJj\r\n\r\n')).toString()).toBe('onetwo');
|
||||
for (const wire of ['3\r\none', '3;signature=unknown\r\none\r\n0\r\n\r\n', '0\r\nunknown:secret\r\n\r\n']) expect(() => decodeMaintenanceAwsChunks(Buffer.from(wire))).toThrow();
|
||||
});
|
||||
it('retries only observed transient inspection states and rejects durable blockers even alongside them', () => {
|
||||
const checks = [
|
||||
{ reason: 'local.registered_probes', state: 'present' }, { reason: 'local.inspection', state: 'present' },
|
||||
{ reason: 'backup.checkpoint', state: 'not_checked' }, { reason: 'inspection.hold', state: 'none' },
|
||||
{ reason: 'inspection.generation', state: 'unknown' }, { reason: 'local.tracked_work', state: 'present' },
|
||||
{ reason: 'plugins.runtime_drain', state: 'not_checked' }, { reason: 'durable.snapshot', state: 'none' },
|
||||
{ reason: 'deadlines.catalog', state: 'none' },
|
||||
];
|
||||
const report = { ownerId: 'owned', draining: true, quiescent: true, expiresAt: '2099-01-01T00:00:00Z', idleSleepSafety: { diagnostics: { complete: false, checks } } };
|
||||
expect(classifyEarlyDiagnostic(report, 90_000)).toBe('transient');
|
||||
const durable = structuredClone(report); durable.idleSleepSafety.diagnostics.checks.push({ reason: 'chat_endpoints', state: 'present' });
|
||||
expect(() => classifyEarlyDiagnostic(durable, 90_000)).toThrow('chat_endpoints');
|
||||
const unknown = structuredClone(report); unknown.idleSleepSafety.diagnostics.checks.push({ reason: 'deadlines.reader', state: 'unknown' });
|
||||
expect(() => classifyEarlyDiagnostic(unknown, 90_000)).toThrow('deadlines.reader');
|
||||
for (const check of report.idleSleepSafety.diagnostics.checks) if (['inspection.generation', 'local.tracked_work', 'plugins.runtime_drain'].includes(check.reason)) check.state = 'none';
|
||||
report.idleSleepSafety.diagnostics.complete = true; expect(classifyEarlyDiagnostic(report, 90_000)).toBe('strict');
|
||||
});
|
||||
|
||||
it('retains observer generation changes and still rejects a second durable report after a transient first', async () => {
|
||||
const checks = [
|
||||
{ reason: 'local.registered_probes', state: 'present' }, { reason: 'local.inspection', state: 'present' },
|
||||
{ reason: 'backup.checkpoint', state: 'not_checked' }, ...['inspection.hold', 'inspection.generation', 'durable.snapshot', 'deadlines.catalog'].map(reason => ({ reason, state: 'none' })),
|
||||
];
|
||||
const strict = { ownerId: 'owned', draining: true, quiescent: true, expiresAt: '2099-01-01T00:00:00Z', idleSleepSafety: { diagnostics: { complete: true, checks } } };
|
||||
const observations: unknown[][] = [];
|
||||
const server = { settled: async () => ({ idle: { active: 0, generation: 10 }, managed: { apiActivityActive: true } }),
|
||||
snapshot: async () => ({ idle: { active: 0, generation: 14 }, managed: { apiActivityActive: true } }) } as unknown as Parameters<typeof stabilizeEarlyDiagnostics>[0];
|
||||
const accepted = await stabilizeEarlyDiagnostics(server, { ownerId: 'owned', apiSucceededAt: performance.now(), read: async () => structuredClone(strict), persist: async values => { observations.push(structuredClone(values)); } });
|
||||
expect(accepted.rounds).toBe(1); expect(observations.at(-1)).toHaveLength(4);
|
||||
const transient = structuredClone(strict); transient.idleSleepSafety.diagnostics.complete = false;
|
||||
transient.idleSleepSafety.diagnostics.checks.find(check => check.reason === 'inspection.generation')!.state = 'unknown';
|
||||
const blocked = structuredClone(transient); blocked.idleSleepSafety.diagnostics.checks.push({ reason: 'chat_endpoints', state: 'present' });
|
||||
let reads = 0;
|
||||
await expect(stabilizeEarlyDiagnostics(server, { ownerId: 'owned', apiSucceededAt: performance.now(), read: async () => reads++ ? blocked : transient,
|
||||
persist: async values => { observations.push(structuredClone(values)); } })).rejects.toThrow('chat_endpoints');
|
||||
expect(reads).toBe(2);
|
||||
});
|
||||
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user