Merge origin/master for branch Storybook deployment

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 09:17:22 -05:00
commit 535ff2bce0
92 changed files with 4223 additions and 512 deletions

View File

@ -0,0 +1,50 @@
// Run before building, and again inside the protected deployment job on reruns.
module.exports = async function authorizeStorybookDeploy({ github, context }) {
const fail = (message) => { throw new Error(message); };
if (context.repo.owner !== "paperclipai" || context.repo.repo !== "paperclip") {
fail("Storybook publishing is restricted to paperclipai/paperclip.");
}
if (context.eventName !== "workflow_dispatch" || !context.ref.startsWith("refs/heads/")) {
fail("Storybook publishing requires a manual run from a repository branch.");
}
// The selected branch must never be able to add itself to the allowlist.
const { data: repository } = await github.rest.repos.get(context.repo);
const { data: file } = await github.rest.repos.getContent({
...context.repo,
path: ".github/CODEOWNERS",
ref: repository.default_branch,
});
if (file.encoding !== "base64" || typeof file.content !== "string") {
fail("Cannot read the default branch CODEOWNERS file.");
}
const owners = new Set();
for (const line of Buffer.from(file.content, "base64").toString("utf8").split(/\r?\n/)) {
const fields = line.split("#", 1)[0].trim().split(/\s+/);
for (const owner of fields.slice(1)) {
// Individual GitHub accounts only. Teams/email entries do not grant access.
if (/^@[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(owner)) {
owners.add(owner.slice(1).toLowerCase());
}
}
}
if (owners.size === 0) fail("CODEOWNERS has no individual GitHub accounts.");
for (const actor of [context.actor, process.env.GITHUB_TRIGGERING_ACTOR]) {
if (!actor || !owners.has(actor.toLowerCase())) {
fail(`Only default-branch CODEOWNERS may publish Storybook (${actor || "missing actor"}).`);
}
}
// A branch can edit its workflow. Require a GitHub-enforced CODEOWNER review
// as well, so editing this check cannot grant an outsider deployment access.
const { data: environment } = await github.rest.repos.getEnvironment({
...context.repo,
environment_name: "storybook-deploy",
});
const reviewers = environment.protection_rules
?.find((rule) => rule.type === "required_reviewers")?.reviewers;
if (environment.can_admins_bypass !== false || !reviewers?.length ||
reviewers.some(({ type, reviewer }) => type !== "User" || !owners.has(reviewer.login.toLowerCase()))) {
fail("storybook-deploy must require CODEOWNER reviewers and disable administrator bypass.");
}
};

44
.github/scripts/publish-storybook.cjs vendored Normal file
View File

@ -0,0 +1,44 @@
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const { storybookDestination, branchIndex } = require('./storybook-destination.cjs');
const destination = storybookDestination({
branch: process.env.SOURCE_BRANCH, sha: process.env.SOURCE_SHA,
runId: process.env.GITHUB_RUN_ID, runAttempt: process.env.GITHUB_RUN_ATTEMPT,
bucket: process.env.STORYBOOK_S3_BUCKET, baseUrl: process.env.STORYBOOK_PUBLIC_BASE_URL,
});
const source = path.resolve('storybook-static');
// Treat the artifact as public files, never as executable publisher code.
function validateTree(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isSymbolicLink() || entry.name.startsWith('.') || (!entry.isDirectory() && !entry.isFile())) {
throw new Error(`Unsupported artifact entry: ${path.join(dir, entry.name)}`);
}
if (entry.isDirectory()) validateTree(path.join(dir, entry.name));
}
}
validateTree(source);
for (const name of ['index.html', 'iframe.html', 'index.json']) {
if (!fs.statSync(path.join(source, name)).isFile() || fs.statSync(path.join(source, name)).size === 0) {
throw new Error(`Missing Storybook output: ${name}`);
}
}
fs.writeFileSync(path.join(source, 'deployment.json'), JSON.stringify(destination, null, 2) + '\n');
const aws = (args) => execFileSync('aws', args, { stdio: 'inherit' });
// Complete a unique build before changing the branch's entry point. No deletion
// permissions, shared root writes or mixed-version branch assets are needed.
aws(['s3', 'cp', source, `s3://${destination.bucket}/${destination.buildPrefix}/`,
'--recursive', '--no-follow-symlinks', '--only-show-errors',
'--cache-control', 'public,max-age=31536000,immutable']);
const indexFile = path.join(process.env.RUNNER_TEMP, 'storybook-branch-index.html');
fs.writeFileSync(indexFile, branchIndex(destination.buildUrl));
aws(['s3', 'cp', indexFile, `s3://${destination.bucket}/${destination.prefix}/index.html`,
'--content-type', 'text/html; charset=utf-8', '--cache-control', 'no-cache,max-age=0,must-revalidate', '--only-show-errors']);
const report = `[Branch Storybook](${destination.url})\n\n[This build](${destination.buildUrl})\n\nCommit: \`${destination.sha}\`\n`;
const reportPath = path.join(process.env.RUNNER_TEMP, 'storybook-deployment.md');
fs.writeFileSync(reportPath, report);
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
`url=${destination.url}\nbuild_url=${destination.buildUrl}\nreport_path=${reportPath}\n`);
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
console.log(JSON.stringify(destination));

View File

@ -0,0 +1,35 @@
const { createHash } = require('node:crypto');
function storybookDestination({ branch, sha, runId, runAttempt, bucket, baseUrl }) {
if (typeof branch !== 'string' || !branch || /[\x00-\x20\x7f]/.test(branch)) {
throw new Error('A non-empty repository branch name is required.');
}
if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error('A full source commit SHA is required.');
if (![runId, runAttempt].every((value) => /^[1-9]\d*$/.test(String(value)))) {
throw new Error('A valid workflow run and attempt are required.');
}
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)) throw new Error('Invalid Storybook S3 bucket.');
const base = new URL(baseUrl);
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || base.pathname !== '/') {
throw new Error('Storybook base URL must be a credential-free HTTPS origin.');
}
const label = branch.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0,60) || 'branch';
const digest = createHash('sha256').update(branch).digest('hex').slice(0,16);
const branchKey = `${label}-${digest}`;
const prefix = `storybook/branches/${branchKey}`;
const buildPrefix = `${prefix}/builds/${runId}-${runAttempt}`;
return {
branch, sha, bucket, branchKey, prefix, buildPrefix,
url: `${base.origin}/${prefix}/index.html`,
buildUrl: `${base.origin}/${buildPrefix}/index.html`,
};
}
function branchIndex(buildUrl) {
// The target is generated from a validated origin and ASCII path segments.
const target = JSON.stringify(buildUrl).replace(/</g, '\\u003c');
return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Storybook preview</title>
<script>const target = new URL(${target}); target.search = location.search; target.hash = location.hash; location.replace(target.href);</script>
<noscript><a href="${buildUrl}">Open this branch's Storybook</a></noscript></html>\n`;
}
module.exports = { storybookDestination, branchIndex };

View File

@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { waitForCloudArtifacts } from "../../../scripts/cloud-readiness.mjs";
import { previewManifest } from "../../../scripts/preview-artifacts.mjs";
const sha = "a".repeat(40);
const digest = `sha256:${"b".repeat(64)}`;
const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
function registry({ missing = new Set(), failure, wrongImage = false, wrongPackage = false } = {}) {
return async (url) => {
if (failure) return json({}, failure);
if (url.startsWith("https://registry.npmjs.org/")) {
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
if (missing.has(name.split("/")[1])) return json({}, 404);
const pkg = previewManifest({ name, version: "0.0.0" }, sha);
return json({ ...pkg, ...(wrongPackage ? { gitHead: "c".repeat(40) } : {}), dist: { integrity: "sha512-fixture", tarball: "https://registry.npmjs.org/fixture.tgz" } });
}
if (url.includes("/token?")) return json({ token: "fixture" });
if (url.includes("/manifests/")) return missing.has("image") ? json({}, 404) : json({ config: { digest } });
if (url.includes("/blobs/")) return json({ config: { Labels: { "org.opencontainers.image.revision": wrongImage ? "c".repeat(40) : sha } } });
throw new Error(`Unexpected request: ${url}`);
};
}
test("readiness requires the image and both exact-source packages on the successful poll", async () => {
const missing = new Set(["image", "shared", "db"]);
let clock = 0;
const states = [];
const result = await waitForCloudArtifacts(sha, {
fetchImpl: registry({ missing }), now: () => clock, intervalMs: 10, timeoutMs: 100, log: (message) => states.push(message),
sleep: async (ms) => {
clock += ms;
if (clock === 10) missing.delete("image");
if (clock === 20) missing.delete("shared");
if (clock === 30) { missing.delete("db"); missing.add("image"); }
if (clock === 40) missing.delete("image");
},
});
assert.equal(clock, 40, "an artifact disappearing before the final poll must prevent readiness");
assert.deepEqual(result, { version: 1, sha, packageVersion: `0.0.0-preview.g${sha}` });
assert.match(states.at(-1), /Cloud artifacts available/);
});
test("missing artifacts time out with a precise inventory and bounded sleep", async () => {
let clock = 0;
const sleeps = [];
await assert.rejects(waitForCloudArtifacts(sha, {
fetchImpl: registry({ missing: new Set(["db"]) }), now: () => clock, timeoutMs: 25, intervalMs: 20, log: () => {},
sleep: async (ms) => { sleeps.push(ms); clock += ms; },
}), /timed out.*missing: db/);
assert.deepEqual(sleeps, [20, 5]);
});
for (const fixture of [{ failure: 403 }, { failure: 503 }, { wrongImage: true }, { wrongPackage: true }]) {
test(`registry errors and identity mismatches fail without waiting: ${JSON.stringify(fixture)}`, async () => {
await assert.rejects(waitForCloudArtifacts(sha, {
fetchImpl: registry(fixture), sleep: async () => assert.fail("must not retry an invalid artifact or upstream error"), log: () => {},
}));
});
}
test("invalid source and timing configuration are rejected before registry access", async () => {
const fetchImpl = async () => assert.fail("invalid inputs must not reach a registry");
await assert.rejects(waitForCloudArtifacts("master", { fetchImpl }), /full immutable commit SHA/);
for (const options of [{ timeoutMs: 0 }, { intervalMs: -1 }, { timeoutMs: Infinity }]) {
await assert.rejects(waitForCloudArtifacts(sha, { ...options, fetchImpl }), /positive finite/);
}
});
test("the versioned readiness job requires successful source, image and artifact jobs", () => {
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
assert.match(workflow, /push:\s*\n\s*branches: \[master\]/);
assert.match(workflow, /group: cloud-readiness-\$\{\{ github.sha \}\}/);
assert.match(workflow, /uses: \.\/\.github\/workflows\/release-verify.yml\s+with:\s+ref: \$\{\{ github.sha \}\}/);
assert.match(workflow, /uses: \.\/\.github\/workflows\/docker-cloud.yml/);
const ready = workflow.split(" ready:")[1];
assert.match(ready, /name: Cloud deployable v1/);
assert.match(ready, /needs: \[verify, image, artifacts\]/);
assert.match(ready, /if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'/);
assert.doesNotMatch(ready, /^\s*(?:if:.*always\(|continue-on-error:)/m);
assert.doesNotMatch(workflow, /secrets: inherit|id-token: write|actions: write|checks: write|uses: .*@v\d\b/);
const cloud = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
assert.doesNotMatch(cloud, /^ push:/m, "the master image must build only once");
const migrator = readFileSync(new URL("../../workflows/cloud-artifacts.yml", import.meta.url), "utf8");
assert.match(migrator, /push:\s*\n\s*branches: \[master\]/);
assert.match(migrator, /SOURCE_SHA: \$\{\{ github.sha \}\}/);
assert.match(migrator, /gh workflow run release.yml .*--ref master/);
assert.match(migrator, /--field channel=cloud-migrator/);
assert.match(migrator, /--field source_ref="\$SOURCE_SHA"/);
});

View File

@ -0,0 +1,65 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
const step = workflow.split(" - name: Free runner disk")[1].split(" - name: Login to GitHub Container Registry")[0];
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
const threshold = 64 * 1024 * 1024;
for (const { name, dockerFree, workspaceFree, dfStatus = "0", infoStatus = "0", cleanup } of [
{ name: "ample free space", dockerFree: threshold + 1, workspaceFree: threshold + 1, cleanup: false },
{ name: "exactly the headroom threshold", dockerFree: threshold, workspaceFree: threshold, cleanup: false },
{ name: "Docker filesystem below threshold", dockerFree: threshold - 1, workspaceFree: threshold + 1, cleanup: true },
{ name: "workspace filesystem below threshold", dockerFree: threshold + 1, workspaceFree: threshold - 1, cleanup: true },
{ name: "invalid Docker measurement", dockerFree: "unknown", workspaceFree: threshold + 1, cleanup: true },
{ name: "invalid workspace measurement", dockerFree: threshold + 1, workspaceFree: "unknown", cleanup: true },
{ name: "failed df command", dockerFree: threshold + 1, workspaceFree: threshold + 1, dfStatus: "1", cleanup: true },
{ name: "failed Docker inspection", dockerFree: threshold + 1, workspaceFree: threshold + 1, infoStatus: "1", cleanup: true },
]) {
test(`cloud disk cleanup: ${name}`, () => {
const dir = mkdtempSync(path.join(tmpdir(), "cloud-disk-test-"));
const log = path.join(dir, "commands.log");
// Every mutating command is a recording fixture; no real SDKs, caches,
// images, or directories are deleted when the workflow shell executes.
const fixture = `#!/bin/bash
printf '%s %s\\n' "\${0##*/}" "$*" >> "$COMMAND_LOG"
case "\${0##*/}" in
df)
printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\\n'
if [ "$1" = '-Pk' ]; then
printf '/dev/docker 200000000 1 %s 1%% /docker\\n' "$DOCKER_FREE"
printf '/dev/workspace 200000000 1 %s 1%% /workspace\\n' "$WORKSPACE_FREE"
exit "$DF_STATUS"
fi
;;
docker)
if [ "$1" = 'info' ]; then
printf '/docker-data\\n'
exit "$INFO_STATUS"
fi
;;
esac
`;
try {
for (const command of ["df", "docker", "pnpm", "sudo"]) {
writeFileSync(path.join(dir, command), fixture, { mode: 0o755 });
}
const result = spawnSync("bash", ["-c", script], {
encoding: "utf8",
env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, GITHUB_WORKSPACE: "/workspace", COMMAND_LOG: log,
DOCKER_FREE: String(dockerFree), WORKSPACE_FREE: String(workspaceFree), DF_STATUS: dfStatus, INFO_STATUS: infoStatus },
});
assert.equal(result.status, 0, result.stderr);
const commands = readFileSync(log, "utf8");
if (infoStatus === "0") assert.match(commands, /df -Pk \/docker-data \/workspace/);
assert.equal(commands.includes("pnpm store prune"), cleanup);
assert.equal(commands.includes("sudo rm -rf /usr/share/dotnet"), cleanup);
assert.equal(commands.includes("docker system prune -af"), cleanup);
assert.equal(result.stdout.includes("skipping cleanup"), !cleanup);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
}

View File

@ -6,6 +6,7 @@ const workflows = [
'.github/workflows/refresh-lockfile.yml',
'.github/workflows/pr-trusted.yml',
'.github/workflows/docker.yml',
'.github/workflows/docker-cloud.yml',
];
test('lockfile repair workflows resolve dependencies instead of updating metadata only', async () => {

View File

@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const workflow = readFileSync(new URL("../../workflows/release-verify.yml", import.meta.url), "utf8");
const runner = workflow.split(" verify_paperclip_runner:")[1].split(" build:")[0];
test("Runner dependency caching selects the package's pinned compiler before computing its key", () => {
const select = runner.indexOf(" - name: Select the pinned Runner Rust toolchain");
const cache = runner.indexOf(" - name: Cache Runner Rust dependencies");
assert.ok(select >= 0 && cache > select);
const setup = runner.slice(select, cache);
assert.match(setup, /working-directory: packages\/paperclip-runner/);
assert.match(setup, /rustup show active-toolchain/);
assert.match(setup, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/);
assert.match(runner, /uses: Swatinem\/rust-cache@[0-9a-f]{40} # v[0-9.]+/);
assert.match(runner, /workspaces: packages\/paperclip-runner\/runner -> target/);
assert.match(runner, /shared-key: release-runner-v1/);
});
test("the shared cache excludes workspace artifacts and only restores or saves the exact master-push source", () => {
assert.match(runner, /cache-workspace-crates: false/);
assert.match(runner, /cache-bin: false/);
const saveIf = runner.match(/^\s*save-if: (.+)$/m)?.[1];
assert.equal(saveIf, "${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}");
const cacheStep = runner.split(" - name: Cache Runner Rust dependencies")[1].split(" - name: Install dependencies")[0];
assert.equal(cacheStep.match(/^\s*if: (.+)$/m)?.[1], saveIf);
assert.doesNotMatch(runner, /cache-on-failure: true|cache-all-crates: true/);
});
test("cache hits cannot bypass Runner verification", () => {
const verify = runner.split(" - name: Verify Paperclip Runner")[1];
assert.match(verify, /run: pnpm --filter @paperclipai\/paperclip-runner check:all/);
assert.doesNotMatch(verify, /if:|continue-on-error:/);
assert.ok(runner.indexOf("Cache Runner Rust dependencies") < runner.indexOf(" - name: Verify Paperclip Runner\n"));
assert.doesNotMatch(runner, /id-token: write|packages: write|secrets: inherit/);
});

26
.github/scripts/verify-storybook.cjs vendored Normal file
View File

@ -0,0 +1,26 @@
const { branchIndex } = require('./storybook-destination.cjs');
async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fetch,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), attempts = 6 }) {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const [metadata, index] = await Promise.all([
fetch(new URL('deployment.json', buildUrl), { signal: AbortSignal.timeout(15000) }),
fetch(branchUrl, { signal: AbortSignal.timeout(15000) }),
]);
if (!metadata.ok || !index.ok) throw new Error(`Public deployment returned HTTP ${metadata.status}/${index.status}.`);
if ((await metadata.json()).sha !== sha) throw new Error('Public build has the wrong source commit.');
if ((await index.text()) !== branchIndex(buildUrl)) throw new Error('Public branch URL does not point to this build.');
return;
} catch (error) {
if (attempt === attempts) throw error;
await sleep(10000);
}
}
}
module.exports = { verifyStorybook };
if (require.main === module) {
verifyStorybook({ branchUrl: process.env.BRANCH_URL, buildUrl: process.env.BUILD_URL,
sha: process.env.SOURCE_SHA }).catch((error) => { console.error(error); process.exitCode = 1; });
}

View File

@ -0,0 +1,10 @@
{
"Sid": "AllowCloudFrontReadStorybook",
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/storybook/branches/*",
"Condition": {"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::078455283791:distribution/E3GTU28BBO2SFR"
}}
}

View File

@ -0,0 +1,12 @@
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::078455283791:oidc-provider/token.actions.githubusercontent.com"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:paperclipai/paperclip:environment:storybook-deploy"
}}
}]
}

View File

@ -0,0 +1,8 @@
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload"],
"Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/storybook/branches/*"
}]
}

34
.github/workflows/cloud-artifacts.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Cloud artifacts
on:
push:
branches: [master]
workflow_dispatch:
permissions: {}
jobs:
dispatch_migrator:
name: Start exact-source cloud migrator publication
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
steps:
# This separate workflow starts at merge, outside the full npm release's
# concurrency group. Publication stays in release.yml so npm recognizes
# the established trusted-publisher identity and npm-canary environment.
# No source checkout or package code runs with the dispatch credential.
- name: Dispatch the migrator-only release
env:
GH_TOKEN: ${{ github.token }}
SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
request_id="$(cat /proc/sys/kernel/random/uuid)"
gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref master \
--field channel=cloud-migrator \
--field source_ref="$SOURCE_SHA" \
--field request_id="$request_id"
echo "Started Cloud migrator $SOURCE_SHA in release.yml (request $request_id)." >> "$GITHUB_STEP_SUMMARY"

66
.github/workflows/cloud-readiness.yml vendored Normal file
View File

@ -0,0 +1,66 @@
name: Cloud readiness
run-name: Cloud readiness ${{ github.sha }}
on:
push:
branches: [master]
workflow_dispatch:
permissions: {}
# Source verification must start outside the full npm release's queue.
concurrency:
group: cloud-readiness-${{ github.sha }}
cancel-in-progress: false
jobs:
image:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker-cloud.yml
verify:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
permissions:
contents: read
uses: ./.github/workflows/release-verify.yml
with:
ref: ${{ github.sha }}
artifacts:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
name: Wait for exact-source cloud artifacts
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Wait for verified image and exact-source migrator
env:
SOURCE_SHA: ${{ github.sha }}
run: node scripts/cloud-readiness.mjs "$SOURCE_SHA"
ready:
# Versioned consumer contract. Never add always() or continue-on-error:
# failed, cancelled, or skipped prerequisites must not report readiness.
name: Cloud deployable v1
needs: [verify, image, artifacts]
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Record cloud readiness
env:
SOURCE_SHA: ${{ github.sha }}
run: |
echo "Cloud deployable v1: $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
echo "Source verification passed; the full-SHA image and exact-source migrator are available." >> "$GITHUB_STEP_SUMMARY"
echo "Deployment tooling must still resolve and pin the image and migrator and validate migration compatibility." >> "$GITHUB_STEP_SUMMARY"

296
.github/workflows/docker-cloud.yml vendored Normal file
View File

@ -0,0 +1,296 @@
name: Docker cloud
on:
workflow_dispatch:
workflow_call:
permissions: {}
# Independent SHAs can build immediately on separate existing hosted runners.
# Repeated requests for the same source serialize without cancelling a build.
# No mutable canary channel is promoted here; docker.yml owns that operation.
concurrency:
group: docker-cloud-${{ github.sha }}
cancel-in-progress: false
jobs:
build-and-push-cloud:
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Full history and tags so `git describe` below can compute the
# release version to stamp into the image.
fetch-depth: 0
# `.git` is dockerignored, so a running image cannot derive its own
# version and otherwise reports the source package.json placeholder in
# analytics and the debug panel. Compute it here from the pristine
# checkout (real CalVer drift from the nearest release tag) and pass it
# into the build. Empty when no release tag is reachable — the server
# then keeps its existing fallbacks.
- name: Compute build version
id: build-version
run: |
set -euo pipefail
case "${GITHUB_REF}" in
refs/tags/nightly/v*)
# Lane tags carry the exact published version; stamp it verbatim
# instead of describing drift from the nearest stable tag.
version="${GITHUB_REF#refs/tags/nightly/v}"
;;
refs/tags/beta/v*)
version="${GITHUB_REF#refs/tags/beta/v}"
;;
*)
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
;;
esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Stamping build version: ${version:-<none>}"
# ISO week stamp for the Dockerfile's tool layer: the layer caches
# across commits and re-pulls the @latest CLI tools when the week rolls
# over, instead of on every build.
- name: Compute tool cache epoch
id: tools-epoch
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
# Each SHA exports its own cache. Import recent first-parent caches so
# a late older build cannot overwrite a newer build's cache manifest.
# The legacy ref keeps the first builds warm during the transition.
- name: Select cloud cache ancestry
id: cloud-cache
env:
CACHE_IMAGE: ghcr.io/${{ github.repository }}
run: |
set -euo pipefail
{
echo 'sources<<CACHE_SOURCES'
for commit in $(git rev-list --first-parent --max-count=10 HEAD); do
echo "type=registry,ref=$CACHE_IMAGE:buildcache-cloud-$commit"
done
echo "type=registry,ref=$CACHE_IMAGE:buildcache-cloud"
echo 'CACHE_SOURCES'
} >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
version: 9.15.4
run_install: false
# No dependency cache here: this workflow publishes release images, and
# restoring a shared Actions cache into the build inputs would let a
# poisoned cache entry reach the published artifact.
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Refresh lockfile for Docker build context
run: |
set -euo pipefail
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile already matches package metadata."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
- name: Free runner disk
run: |
set -euo pipefail
echo "Disk before cleanup:"
df -h
# A measured hosted cloud build started with 86 GB available.
# Keep ample headroom for BuildKit and image verification, but
# avoid minutes deleting SDKs when neither filesystem needs space.
minimum_free_kib=$((64 * 1024 * 1024))
if docker_root="$(docker info --format '{{.DockerRootDir}}')" \
&& available_kib="$(df -Pk "$docker_root" "$GITHUB_WORKSPACE" | awk 'NR > 1 { rows++; if ($4 !~ /^[0-9]+$/) invalid = 1; if (min == "" || $4 < min) min = $4 } END { if (invalid || rows != 2) exit 1; print min }')" \
&& [[ "$available_kib" =~ ^[0-9]+$ ]] \
&& (( available_kib >= minimum_free_kib )); then
echo "At least 64 GiB is available for Docker and the workspace; skipping cleanup."
exit 0
fi
pnpm store prune || true
sudo apt-get clean || true
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/boost \
/usr/local/share/powershell \
/opt/ghc \
/opt/hostedtoolcache/CodeQL \
/opt/hostedtoolcache/PyPy \
/opt/hostedtoolcache/Ruby || true
docker system prune -af || true
echo "Disk after cleanup:"
df -h
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Deployment tooling reads these labels from the registry to verify an
# image's schema expectations against a migrator before deploying it,
# without pulling the image. The server refuses to start when the
# database is missing bundled migrations, so orchestrators need a cheap
# way to check image/migrator compatibility up front.
- name: Compute schema migration labels
id: schema
run: |
set -euo pipefail
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
# Published under the same lane tag set as the self-hosted image, with a
# `-cloud` suffix (nightly-cloud, latest-cloud, <version>-cloud,
# sha-<short>-cloud). `:canary-cloud` follows the same retag-step
# ownership rule as `:canary` above.
- name: Docker meta (cloud)
id: meta-cloud
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: ghcr.io/${{ github.repository }}
flavor: |
suffix=-cloud,onlatest=true
tags: |
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
- name: Build and push (cloud)
id: build-cloud
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
target: cloud
# Space-separated sandbox-provider directory names to build into
# the variant; add here when managed deployments need another.
# CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the
# variant installs from server/package.json's declared version;
# add another name there when a managed tenant needs it.
build-args: |
USER_UID=1001
USER_GID=1001
CLOUD_BUNDLED_PLUGINS=daytona
CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
# amd64 only, unlike the self-hosted image above: the cloud variant
# is consumed exclusively by managed-deployment hosts, which run
# amd64. The QEMU-emulated arm64 half dominated this job's wall
# clock, and dropping it roughly halves time-to-deployable-image.
platforms: linux/amd64
push: true
# Same-SHA builds serialize above; different SHAs never share a
# writable cache ref. Registry layers are content-addressed and
# shared even when cache manifests have separate tags.
cache-from: ${{ steps.cloud-cache.outputs.sources }}
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud-${{ github.sha }},mode=max
tags: ${{ steps.meta-cloud.outputs.tags }}
labels: ${{ steps.meta-cloud.outputs.labels }}
# The cloud target installs @sentry/node at the version
# server/package.json declares, into a directory the server's own
# module resolution walks. Verify the image this job just pushed, not
# a local build, so a build-cache or layer-ordering regression is
# caught before any tenant runs the image.
- name: Verify the pushed image resolves the declared Sentry version
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
run: |
set -euo pipefail
expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")"
test -n "$expected"
installed="$(docker run --rm --pull always \
-v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \
--entrypoint node "$IMAGE" /app/server/.ci-sentry-probe.mjs)"
echo "Declared optional peer version: $expected"
echo "Installed in the pushed image: $installed"
if [ "$installed" != "$expected" ]; then
echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2
exit 1
fi
echo "The pushed image resolves the declared @sentry/node version."
# Managed hosts run node as 1001:1001. Bake that identity into the image
# so usermod does not walk the mounted home on every container start.
# Check before the entrypoint can repair a wrongly built identity.
- name: Verify cloud runtime user
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
run: |
set -euo pipefail
docker run --rm --entrypoint sh "$IMAGE" -ec '
test "$(id -u node)" = 1001
test "$(id -g node)" = 1001
test "$USER_UID" = 1001
test "$USER_GID" = 1001
'
docker run --rm -e USER_UID=1001 -e USER_GID=1001 "$IMAGE" sh -ec '
test "$(id -u)" = 1001
test "$(id -g)" = 1001
test -w "$PAPERCLIP_HOME"
'
# Verify the independently published cloud image without waiting for
# the self-hosted manifest job. The Sentry check already pulled it.
- name: Verify cloud PID 1 reaps orphaned processes
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
run: docker run --rm -i "$IMAGE" sh -s < scripts/assert-orphan-reaping.sh
# Cloud's commit resolver and preview-artifact planner use the full SHA.
# Publish that address only after checking this build's exact digest.
# Retagging reuses the registry manifest and does not rebuild the image.
- name: Publish verified full-SHA cloud tag
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
FULL_SHA_TAG: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}-cloud
run: |
set -euo pipefail
revision="$(docker image inspect "$IMAGE" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')"
platform="$(docker image inspect "$IMAGE" --format '{{ .Os }}/{{ .Architecture }}')"
test "$revision" = "$GITHUB_SHA"
test "$platform" = linux/amd64
docker buildx imagetools create --prefer-index=false --tag "$FULL_SHA_TAG" "$IMAGE"

View File

@ -0,0 +1,37 @@
name: Docker Runner check
on:
pull_request:
paths:
- .github/workflows/docker-runner-check.yml
- Dockerfile
- .dockerignore
- packages/paperclip-runner/rust-toolchain.toml
- packages/paperclip-runner/runner/**
- packages/paperclip-runner/protocol/**
permissions: {}
concurrency:
group: docker-runner-check-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
runner:
name: Compile isolated native Runner
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Compile the real target with the real .dockerignore. This catches new
# Cargo or embedded protocol inputs that the isolated COPY set omits.
# No registry credentials, cache imports/exports, or image publication.
- name: Compile the Runner from its isolated Docker context
run: docker buildx build --target runner-build --progress plain .

View File

@ -349,8 +349,7 @@ jobs:
# until the cgroup pid limit is exhausted and every fork() in the
# container fails. Run against the pushed manifest rather than a local
# build: the legs push by digest, so nothing is loaded into this
# runner's daemon. The cloud variant is FROM production and inherits the
# same ENTRYPOINT, so checking this image covers both.
# runner's daemon. The independent cloud workflow checks its own image.
- name: Verify PID 1 reaps orphaned processes
env:
# Through the environment, not interpolated into the script body, so
@ -363,220 +362,15 @@ jobs:
echo "Verifying orphan reaping in $image"
docker run --rm -i --pull always "$image" sh -s < scripts/assert-orphan-reaping.sh
# The cloud variant carries built bundled plugins for managed deployments
# (see the `cloud` stage in the Dockerfile). It runs as its own job with no
# `needs:` on the stock publish above, so the two builds run in parallel and
# a failure or slow build in one never gates, delays, or skips the other.
# Both jobs share only the single top-level concurrency slot. Each job is a
# separate runner, so this one carries its own copy of the prep steps
# (checkout through schema labels) — the accepted cost of that isolation.
# Master cloud builds start independently in docker-cloud.yml. Tag builds
# and manual Docker dispatches call the same implementation, preserving the
# release tags and the canary promotion dependency below.
build-and-push-cloud:
runs-on: ubuntu-latest
timeout-minutes: 60
if: github.event_name != 'push' || github.ref != 'refs/heads/master'
uses: ./.github/workflows/docker-cloud.yml
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# Full history and tags so `git describe` below can compute the
# release version to stamp into the image.
fetch-depth: 0
# `.git` is dockerignored, so a running image cannot derive its own
# version and otherwise reports the source package.json placeholder in
# analytics and the debug panel. Compute it here from the pristine
# checkout (real CalVer drift from the nearest release tag) and pass it
# into the build. Empty when no release tag is reachable — the server
# then keeps its existing fallbacks.
- name: Compute build version
id: build-version
run: |
set -euo pipefail
case "${GITHUB_REF}" in
refs/tags/nightly/v*)
# Lane tags carry the exact published version; stamp it verbatim
# instead of describing drift from the nearest stable tag.
version="${GITHUB_REF#refs/tags/nightly/v}"
;;
refs/tags/beta/v*)
version="${GITHUB_REF#refs/tags/beta/v}"
;;
*)
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
;;
esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Stamping build version: ${version:-<none>}"
# ISO week stamp for the Dockerfile's tool layer: the layer caches
# across commits and re-pulls the @latest CLI tools when the week rolls
# over, instead of on every build.
- name: Compute tool cache epoch
id: tools-epoch
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
# No dependency cache here: this workflow publishes release images, and
# restoring a shared Actions cache into the build inputs would let a
# poisoned cache entry reach the published artifact.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
- name: Refresh lockfile for Docker build context
run: |
set -euo pipefail
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile already matches package metadata."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
- name: Free runner disk
run: |
set -euo pipefail
echo "Disk before cleanup:"
df -h
pnpm store prune || true
sudo apt-get clean || true
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/boost \
/usr/local/share/powershell \
/opt/ghc \
/opt/hostedtoolcache/CodeQL \
/opt/hostedtoolcache/PyPy \
/opt/hostedtoolcache/Ruby || true
docker system prune -af || true
echo "Disk after cleanup:"
df -h
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Deployment tooling reads these labels from the registry to verify an
# image's schema expectations against a migrator before deploying it,
# without pulling the image. The server refuses to start when the
# database is missing bundled migrations, so orchestrators need a cheap
# way to check image/migrator compatibility up front.
- name: Compute schema migration labels
id: schema
run: |
set -euo pipefail
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
# Published under the same lane tag set as the self-hosted image, with a
# `-cloud` suffix (nightly-cloud, latest-cloud, <version>-cloud,
# sha-<short>-cloud). `:canary-cloud` follows the same retag-step
# ownership rule as `:canary` above.
- name: Docker meta (cloud)
id: meta-cloud
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
flavor: |
suffix=-cloud,onlatest=true
tags: |
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
- name: Build and push (cloud)
uses: docker/build-push-action@v7
with:
context: .
target: cloud
# Space-separated sandbox-provider directory names to build into
# the variant; add here when managed deployments need another.
# CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the
# variant installs from server/package.json's declared version;
# add another name there when a managed tenant needs it.
build-args: |
CLOUD_BUNDLED_PLUGINS=daytona
CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
# amd64 only, unlike the self-hosted image above: the cloud variant
# is consumed exclusively by managed-deployment hosts, which run
# amd64. The QEMU-emulated arm64 half dominated this job's wall
# clock, and dropping it roughly halves time-to-deployable-image.
platforms: linux/amd64
push: true
# Registry-backed BuildKit cache, separate ref from the self-hosted
# job so the two parallel builds never clobber each other's cache
# manifest (see the rationale on the job above).
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud,mode=max
tags: ${{ steps.meta-cloud.outputs.tags }}
labels: ${{ steps.meta-cloud.outputs.labels }}
# The cloud target installs @sentry/node at the version
# server/package.json declares, into a directory the server's own
# module resolution walks. Verify the image this job just pushed, not
# a local build, so a build-cache or layer-ordering regression is
# caught before any tenant runs the image.
- name: Verify the pushed image resolves the declared Sentry version
env:
IMAGE_TAGS: ${{ steps.meta-cloud.outputs.tags }}
run: |
set -euo pipefail
image="$(printf '%s\n' "$IMAGE_TAGS" | head -n 1)"
test -n "$image"
expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")"
test -n "$expected"
installed="$(docker run --rm --pull always \
-v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \
--entrypoint node "$image" /app/server/.ci-sentry-probe.mjs)"
echo "Declared optional peer version: $expected"
echo "Installed in the pushed image: $installed"
if [ "$installed" != "$expected" ]; then
echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2
exit 1
fi
echo "The pushed image resolves the declared @sentry/node version."
# Moves the mutable `:canary` / `:canary-cloud` channel tags. Kept OUT
# of the build jobs and serialized in its own lane, and — the load-

View File

@ -58,16 +58,39 @@ jobs:
fail-fast: false
matrix:
include:
- group: general-server
group_label: server (1/3)
# Split the long chat file by collected test locations, and balance
# the remaining server files across five runners. Normal PR/local
# invocations retain their complete general-server group.
- group: general-server-without-chat
group_label: server (1/5)
shard_index: 0
shard_count: 5
- group: general-server-without-chat
group_label: server (2/5)
shard_index: 1
shard_count: 5
- group: general-server-without-chat
group_label: server (3/5)
shard_index: 2
shard_count: 5
- group: general-server-without-chat
group_label: server (4/5)
shard_index: 3
shard_count: 5
- group: general-server-without-chat
group_label: server (5/5)
shard_index: 4
shard_count: 5
- group: general-chat
group_label: chat (1/3)
shard_index: 0
shard_count: 3
- group: general-server
group_label: server (2/3)
- group: general-chat
group_label: chat (2/3)
shard_index: 1
shard_count: 3
- group: general-server
group_label: server (3/3)
- group: general-chat
group_label: chat (3/3)
shard_index: 2
shard_count: 3
# Keep parity with pr.yml: workspaces-a is split with Vitest's
@ -215,6 +238,30 @@ jobs:
node-version: 24
cache: pnpm
- name: Select the pinned Runner Rust toolchain
working-directory: packages/paperclip-runner
run: |
set -euo pipefail
rustup show
toolchain="$(rustup show active-toolchain | awk '{print $1}')"
echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV"
- name: Cache Runner Rust dependencies
# Restore and save only within trusted master-push verification. GitHub
# isolates branch/PR caches from master; other callers compile afresh.
if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: packages/paperclip-runner/runner -> target
shared-key: release-runner-v1
# Rebuild workspace code and rerun every check. Cache only compiled
# dependencies; never restore installed executables from cargo/bin.
cache-workspace-crates: false
cache-bin: false
# The step guard also restricts restores. Save only after a successful
# master-push verification of that push's exact commit.
save-if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
- name: Install dependencies
run: pnpm install --no-frozen-lockfile

View File

@ -1,5 +1,5 @@
name: Release
run-name: ${{ inputs.channel == 'preview' && format('Stack deploy {0} build', inputs.request_id) || 'Release' }}
run-name: ${{ inputs.channel == 'preview' && format('Stack deploy {0} build', inputs.request_id) || inputs.channel == 'cloud-migrator' && format('Cloud migrator {0}', inputs.source_ref) || 'Release' }}
on:
push:
@ -19,14 +19,15 @@ on:
- beta
- nightly
- preview
- cloud-migrator
default: stable
source_ref:
description: Stable source ref, or full immutable SHA for a preview build
description: Stable source ref, or full immutable SHA for a preview or cloud migrator build
required: true
type: string
default: master
request_id:
description: (preview) CLI correlation UUID
description: (preview/cloud-migrator) Correlation UUID
type: string
default: ""
preview_migrator:
@ -56,7 +57,7 @@ on:
default: false
concurrency:
group: ${{ inputs.channel == 'preview' && format('preview-{0}', inputs.source_ref) || format('release-{0}-{1}', github.event_name, github.ref) }}
group: ${{ (inputs.channel == 'preview' || inputs.channel == 'cloud-migrator') && format('{0}-{1}', inputs.channel, inputs.source_ref) || format('release-{0}-{1}', github.event_name, github.ref) }}
cancel-in-progress: false
env:
@ -76,7 +77,7 @@ env:
jobs:
plan_preview:
name: Check preview artifacts
if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && inputs.channel == 'preview' && !inputs.dry_run
if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && (inputs.channel == 'preview' || inputs.channel == 'cloud-migrator') && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: read
@ -96,7 +97,8 @@ jobs:
SOURCE_SHA: ${{ inputs.source_ref }}
REQUEST_ID: ${{ inputs.request_id }}
PREVIEW_MIGRATOR: ${{ inputs.preview_migrator }}
run: node scripts/preview-artifacts.mjs plan "$SOURCE_SHA" "$REQUEST_ID" "$PREVIEW_MIGRATOR"
PLAN_COMMAND: ${{ inputs.channel == 'cloud-migrator' && 'plan-migrator' || 'plan' }}
run: node scripts/preview-artifacts.mjs "$PLAN_COMMAND" "$SOURCE_SHA" "$REQUEST_ID" "$PREVIEW_MIGRATOR"
package_preview:
name: Build preview migrator
@ -144,6 +146,12 @@ jobs:
if: github.ref == 'refs/heads/master' && needs.plan_preview.outputs.packages == 'true' && needs.package_preview.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
# A manual preview and a merge-triggered migrator may compile in parallel.
# Serialize only publication so they cannot race an immutable npm version,
# without making the migrator wait for a preview's separate image build.
concurrency:
group: preview-package-publish-${{ inputs.source_ref }}
cancel-in-progress: false
# Reuse release.yml's established npm trusted-publisher identity. This job
# publishes only isolated preview versions; it cannot advance lane tags.
environment: npm-canary
@ -260,7 +268,7 @@ jobs:
name: Verify preview artifacts
needs: [plan_preview, image_preview, publish_image_preview, package_preview, publish_preview]
if: >-
always() && needs.plan_preview.result == 'success' &&
always() && inputs.channel == 'preview' && needs.plan_preview.result == 'success' &&
(needs.publish_image_preview.result == 'success' || needs.plan_preview.outputs.image == 'false') &&
(needs.publish_preview.result == 'success' || needs.plan_preview.outputs.packages == 'false')
runs-on: ubuntu-latest

View File

@ -12,7 +12,9 @@ on:
type: string
concurrency:
group: runner-chaos-evals-${{ inputs.ref || github.ref }}
# Reusable calls inherit the caller's workflow name. Cloud readiness and
# Release verify the same SHA independently and must not cancel each other.
group: runner-chaos-evals-${{ github.workflow }}-${{ inputs.ref || github.ref }}
cancel-in-progress: true
jobs:

169
.github/workflows/storybook-deploy.yml vendored Normal file
View File

@ -0,0 +1,169 @@
name: Storybook Deploy
on:
workflow_dispatch:
inputs:
branch:
description: "Repository branch to publish (empty uses the selected workflow branch)"
type: string
default: ""
# Also exposed by Storybook Visual, which is already available on master.
workflow_call:
inputs:
branch:
type: string
default: ""
permissions:
contents: read
jobs:
authorize:
name: Authorize Storybook publisher
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
sha: ${{ steps.source.outputs.sha }}
branch: ${{ steps.source.outputs.branch }}
branch_key: ${{ steps.source.outputs.branch_key }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Require CODEOWNER initiator and rerunner
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const authorize = require('./.github/scripts/authorize-storybook-deploy.cjs');
await authorize({ github, context });
- name: Pin requested repository branch
id: source
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
SOURCE_BRANCH: ${{ inputs.branch }}
STORYBOOK_S3_BUCKET: ${{ vars.STORYBOOK_S3_BUCKET }}
STORYBOOK_PUBLIC_BASE_URL: ${{ vars.STORYBOOK_PUBLIC_BASE_URL }}
with:
script: |
const branch = process.env.SOURCE_BRANCH || context.ref.slice('refs/heads/'.length);
const { data } = await github.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` });
if (data.ref !== `refs/heads/${branch}` || data.object.type !== 'commit') {
throw new Error('Select an existing branch in this repository.');
}
// With no source override, preserve the exact dispatched commit.
const sha = process.env.SOURCE_BRANCH ? data.object.sha : context.sha;
const { storybookDestination } = require('./.github/scripts/storybook-destination.cjs');
const destination = storybookDestination({ branch, sha,
runId: context.runId, runAttempt: process.env.GITHUB_RUN_ATTEMPT,
bucket: process.env.STORYBOOK_S3_BUCKET, baseUrl: process.env.STORYBOOK_PUBLIC_BASE_URL });
core.setOutput('sha', sha);
core.setOutput('branch_key', destination.branchKey);
core.setOutput('branch', branch);
build:
name: Build selected branch Storybook
permissions: {}
needs: authorize
runs-on: ubuntu-latest
timeout-minutes: 25
outputs:
artifact_name: ${{ steps.artifact.outputs.name }}
env:
STORYBOOK_DISABLE_TELEMETRY: "1"
steps:
- name: Download public source without repository credentials
env:
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
run: |
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]
curl --fail --silent --show-error --location --retry 3 \
"https://codeload.github.com/paperclipai/paperclip/tar.gz/$SOURCE_SHA" \
--output "$RUNNER_TEMP/source.tar.gz"
tar -xzf "$RUNNER_TEMP/source.tar.gz" --strip-components=1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
version: 9.15.4
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
package-manager-cache: false
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm build-storybook
- name: Record source and validate output
id: artifact
env:
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
SOURCE_BRANCH: ${{ needs.authorize.outputs.branch }}
run: |
test -s ui/storybook-static/index.html
test -s ui/storybook-static/iframe.html
test -s ui/storybook-static/index.json
jq -n --arg sha "$SOURCE_SHA" --arg branch "$SOURCE_BRANCH" \
'{sha: $sha, branch: $branch}' > ui/storybook-static/deployment.json
echo "name=storybook-deploy-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.artifact.outputs.name }}
path: ui/storybook-static
if-no-files-found: error
retention-days: 7
deploy:
name: Publish branch Storybook to S3
needs: [authorize, build]
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: storybook-deploy-${{ needs.authorize.outputs.branch_key }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
environment:
name: storybook-deploy
url: ${{ steps.deployment.outputs.url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Recheck CODEOWNER access before publishing
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const authorize = require('./.github/scripts/authorize-storybook-deploy.cjs');
await authorize({ github, context });
- name: Download the successful build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.build.outputs.artifact_name }}
path: storybook-static
- name: Assume the Storybook-only uploader role
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with:
role-to-assume: ${{ vars.STORYBOOK_AWS_ROLE_ARN }}
aws-region: ${{ vars.STORYBOOK_AWS_REGION }}
role-duration-seconds: 900
- name: Publish this branch preview
id: deployment
env:
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
SOURCE_BRANCH: ${{ needs.authorize.outputs.branch }}
STORYBOOK_S3_BUCKET: ${{ vars.STORYBOOK_S3_BUCKET }}
STORYBOOK_PUBLIC_BASE_URL: ${{ vars.STORYBOOK_PUBLIC_BASE_URL }}
run: node .github/scripts/publish-storybook.cjs
- name: Verify public build and stable branch URL
env:
BUILD_URL: ${{ steps.deployment.outputs.build_url }}
BRANCH_URL: ${{ steps.deployment.outputs.url }}
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
run: node .github/scripts/verify-storybook.cjs
- name: Upload deployment links
if: ${{ !cancelled() && steps.deployment.outcome == 'success' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: storybook-deployment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.deployment.outputs.report_path }}
if-no-files-found: error
retention-days: 30

View File

@ -3,6 +3,16 @@ name: Storybook Visual
on:
workflow_dispatch:
inputs:
branch:
description: "Repository branch to publish (deployment only; empty uses the selected workflow branch)"
required: false
type: string
default: ""
deploy_preview:
description: "Publish this branch to S3/CloudFront instead of running visual tests (CODEOWNERS only)"
required: false
type: boolean
default: false
update_snapshots:
description: "Generate updated snapshots and a baseline review bundle"
required: false
@ -18,7 +28,7 @@ on:
- labeled
concurrency:
group: storybook-visual-${{ github.event.pull_request.number || github.ref }}
group: storybook-visual-${{ inputs.deploy_preview && github.run_id || 'visual' }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
@ -28,7 +38,7 @@ jobs:
visual:
name: Storybook visual regression
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' && !inputs.deploy_preview) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'storybook-visual'))
runs-on: ubuntu-latest
timeout-minutes: 35
@ -100,3 +110,13 @@ jobs:
path: tests/storybook-visual/baseline-review/snapshots.tgz
retention-days: 30
if-no-files-found: error
preview:
name: Deploy selected branch Storybook
if: github.event_name == 'workflow_dispatch' && inputs.deploy_preview
permissions:
contents: read
id-token: write
uses: ./.github/workflows/storybook-deploy.yml
with:
branch: ${{ inputs.branch }}

View File

@ -51,7 +51,7 @@ COPY scripts/link-plugin-dev-sdk.mjs scripts/
RUN pnpm install --frozen-lockfile
FROM base AS build
FROM base AS rust-toolchain
WORKDIR /app
# Debian's packaged rust lags the ecosystem (trixie ships 1.85) and the
# runner's dependency tree now requires a newer rustc. Install rustup from a
@ -83,8 +83,31 @@ RUN set -eux; \
chmod +x /tmp/rustup-init; \
/tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain none; \
rm /tmp/rustup-init
# Install the package-owned compiler before any application source enters the
# stage. rustup-init above installs rustup itself, not the selected compiler.
COPY packages/paperclip-runner/rust-toolchain.toml /tmp/runner-toolchain/rust-toolchain.toml
RUN cd /tmp/runner-toolchain && rustup show
FROM rust-toolchain AS runner-build
WORKDIR /app/packages/paperclip-runner
# Rust embeds protocol schemas and fixtures with include_str!. Keep those
# alongside the complete Cargo workspace so every compile-time input keys
# this layer. Ordinary server/UI edits can then reuse the native build.
COPY packages/paperclip-runner/rust-toolchain.toml ./
COPY packages/paperclip-runner/runner ./runner
COPY packages/paperclip-runner/protocol ./protocol
# Cargo fingerprints source mtimes. Normalize them here and after the full
# source copy below so a fresh checkout cannot invalidate unchanged inputs.
RUN find runner protocol -type f -exec touch -d @0 {} + \
&& touch -d @0 rust-toolchain.toml \
&& cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd
FROM runner-build AS build
WORKDIR /app
COPY --from=deps /app /app
COPY . .
RUN find packages/paperclip-runner/runner packages/paperclip-runner/protocol -type f -exec touch -d @0 {} + \
&& touch -d @0 packages/paperclip-runner/rust-toolchain.toml
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/plugin-sdk build
# The server build runs scripts/write-build-stamp.mjs, which stamps the built
@ -103,14 +126,6 @@ RUN rm -rf packages/paperclip-runner/runner/target
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
# Real version for this build, computed from `git describe` on the CI runner
# (the image has no .git, so the server cannot derive it at runtime). Empty for
# local `docker build`, which just leaves the server on its normal fallbacks.
ARG PAPERCLIP_BUILD_VERSION=""
# The exact commit this image was built from, for the same reason: server-info
# falls back to PAPERCLIP_BUILD_COMMIT when git is unavailable, which feeds the
# /api/health `commit` field that deploy tooling verifies. Empty locally.
ARG PAPERCLIP_BUILD_COMMIT=""
# Refreshes the tool layer below when it changes (CI stamps an ISO week, so
# the @latest CLI tools advance weekly). Without it the cached layer would
# freeze the tools until an unrelated cache bust.
@ -133,6 +148,13 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY --chown=node:node --from=build /app /app
# Declare per-build metadata after the stable RUN layers. Docker includes
# in-scope ARG values in a RUN's environment even when its command does not
# mention them; declaring these earlier invalidates the weekly tool cache.
# The build stage still receives the commit before writing dist/build-info.json.
# Empty for local builds, preserving the server's normal version fallbacks.
ARG PAPERCLIP_BUILD_VERSION=""
ARG PAPERCLIP_BUILD_COMMIT=""
ENV NODE_ENV=production \
HOME=/paperclip \
HOST=0.0.0.0 \

View File

@ -110,6 +110,63 @@ workflow manually, to produce downloadable Playwright report/test-result
artifacts. Normal PR visual runs use read-only repository permissions and do not
upload or mutate baseline objects.
### Publish a branch Storybook
CODEOWNERS can publish a repository branch through **Actions → Storybook Deploy →
Run workflow**. Keep the workflow branch on `master` and enter the source branch
in `branch`. The source branch does not need to contain the workflow. Leaving
`branch` empty publishes the selected workflow branch's dispatched commit.
```sh
gh workflow run storybook-deploy.yml --ref master -f branch=your-branch
```
The existing **Storybook Visual** workflow also offers a `deploy_preview` checkbox,
which publishes through the same workflow instead of running visual tests:
```sh
gh workflow run storybook-visual.yml --ref master -f deploy_preview=true -f branch=your-branch
```
Approve the `storybook-deploy` environment as a CODEOWNER. The workflow summary
links the **stable branch URL** and **this build**. The run also uploads a
`storybook-deployment-<run-id>-<attempt>` artifact containing
`storybook-deployment.md` with both links and the source commit. Different branches have
different URLs; publishing one never replaces another. Redeploying the same
branch updates its stable URL only after all files for the new build are uploaded.
Previous build links keep working. The branch entry preserves Storybook query
parameters and fragments when redirecting to the completed build.
URLs use `storybook/branches/<readable-branch>-<hash>/index.html`. The hash preserves
the distinction between branch names such as `feature/foo`, `feature-foo`, and
`Feature/foo`. Build files live under that branch's `builds/<run-id>-<attempt>/`.
`deployment.json` in each build records its branch, source commit and URLs.
Builds run independently; publication is serialized per branch. Retained builds
are not automatically deleted and will accumulate until an operator prunes them.
Publishing requires both the original actor and the current rerunner to be
individual GitHub accounts named in `.github/CODEOWNERS` on the current default
branch. Comments, teams and email entries do not grant access. Authorization runs
before the build and again before deployment, including deployment-only reruns.
GitHub also requires a CODEOWNER environment approval, so editing authorization
code on a branch cannot grant AWS access without an authorized reviewer.
The build downloads the public source archive with no GitHub token permissions,
AWS credentials or repository secrets. Dependency caching and install lifecycle
scripts are disabled. The separate publisher uses GitHub OIDC to assume a role limited to
`storybook/branches/*`. It treats the build artifact as static files and runs only
the publisher from the workflow checkout. It cannot delete objects, change AWS
settings, or overwrite the runner dashboard. The Storybook site itself is public.
Pushes and PR events never publish it.
The existing S3 bucket and CloudFront distribution also serve runner reports in
separate prefixes. GitHub Pages and its dashboard workflow are independent.
See [Storybook deployment setup](STORYBOOK-DEPLOYMENT.md) for the environment,
repository variables, AWS policies and one-time operator setup.
GitHub requires a new dispatch workflow to exist on the default branch before
it becomes a manual entry point.
## UI Fonts And Screenshots
The board UI ships its own sans-serif webfont assets in `ui/public/fonts/`.
@ -1314,3 +1371,34 @@ See [execution GitHub identity](execution-github-identity.md) for the operation-
See [agent-personas.md](agent-personas.md) for the dynamic avatar endpoint, cache,
and character stories. Set `PAPERCLIP_STORYBOOK_API_URL` to your isolated
Paperclip API URL when running Storybook. Avatar PNGs are generated on demand.
### Investigating polling load
The company heartbeat-run and live-run lists load secret registries in one
company-scoped query per response. Registry reads project only
`paperclipSecretRedactions` from the run context. They do not load the full
prompt/context JSON. Decrypted values live only for that request and each run
uses its own registry.
Hidden browser tabs suspend the company live-events connection and transcript
log reads. Returning to a visible tab refreshes active queries once and resumes
transcript reads from their retained offsets. A queued live-event invalidation
that flushes after the tab hides marks data stale without starting a refetch.
The developer-server health poll also stops in hidden tabs.
Workspace detail responses share concurrent Git inspections and reuse their
results for up to five seconds after completion. The cache holds at most 256
entries. Close-readiness checks, the terminal-workspace reaper, and the final
cleanup validation still inspect Git afresh. A display result never authorizes
worktree removal.
The connection-health sweep selects only due IDs in SQL before applying its
limit. Legacy `paperclip_plugin` placeholder connections are excluded: their
tools run in plugin workers and do not have remote MCP endpoints. These rows
remain available; the sweep does not disable or delete plugin connections.
When investigating an overloaded instance, distinguish request amplification
from stored configuration problems. Verify connection transport and endpoint
fields before disabling a connection. Verify workspace ownership, active runs,
Git state, and runtime-service readiness before closing a workspace. A missing
URL or old workspace timestamp alone does not prove that a row is disposable.

View File

@ -18,12 +18,55 @@ Build arguments:
|-----|---------|---------|
| `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) |
| `USER_GID` | `1000` | GID for the container `node` group |
| `CLI_TOOLS_CACHE_EPOCH` | empty | Refresh the CLI-install layer; CI supplies the current ISO week |
| `PAPERCLIP_BUILD_VERSION` | empty | Runtime version when Git metadata is unavailable |
| `PAPERCLIP_BUILD_COMMIT` | empty | Source commit written into the server build stamp and runtime environment |
Changing the build version or commit preserves the CLI-install cache. The
tool layer refreshes when its weekly epoch, base image, installation command,
or earlier build inputs change. Local builds can set a new epoch explicitly
to refresh tools without clearing the entire build cache.
```sh
docker build -t paperclip-local \
--build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .
```
## Cloud image addresses
The Docker workflow publishes the managed deployment image for Linux AMD64.
`Cloud readiness` starts `Docker cloud` on each master push independently of the
multi-platform self-hosted build. Different commits use separate concurrency groups and existing
GitHub-hosted runners, so an older production or cloud build does not hold the
new commit in a workflow queue. Available GitHub runner capacity still applies.
Release tags and manual `Docker` dispatches call the same cloud build workflow.
Each commit exports to its own `buildcache-cloud-<FULL_SHA>` registry tag.
Builds import the current commit and nine first-parent ancestors, plus the
legacy `buildcache-cloud` fallback. This preserves reusable layers without
letting concurrent builds overwrite one shared cache manifest. Retain recent
cache tags if registry cleanup is configured; deleting them makes builds colder.
Cloud CI skips SDK and cache cleanup when both the Docker data filesystem and
the checkout filesystem have at least 64 GiB available. Below that conservative
headroom threshold, or when the measurement fails, it retains the existing
cleanup. The threshold selects the fast path; it is not a new minimum disk
requirement for local builds or smaller runners.
After the pushed image passes its Sentry and orphan-reaping checks, the workflow verifies its
commit label and platform and adds `ghcr.io/paperclipai/paperclip:sha-<full-commit-sha>-cloud`.
This address lets commit-based deployment tooling reuse the normal build.
Existing short-SHA and release tags remain available.
The full-SHA tag identifies the source commit. It does not certify that source
tests passed or that a compatible database migrator is available. Deployment
tooling must still check those prerequisites and pin the resolved image digest;
a rebuild of the same source can update the tag's digest.
The separate [cloud readiness check](cloud-build-readiness.md) combines source
verification, successful cloud image checks, and exact-source migrator
availability. It runs outside the full npm release's concurrency queue.
## One-liner (build + run)
```sh
@ -295,3 +338,25 @@ Notes:
- The `docker-entrypoint.sh` adjusts the container `node` user UID/GID at startup to match the values passed via `USER_UID`/`USER_GID`, avoiding permission issues on bind-mounted volumes.
- Paperclip data persists via Docker volumes/bind mounts (compose) or at `~/.local/share/paperclip` (quadlet).
## Native Runner build cache
The image compiles the native Runner in `runner-build`, before copying the
application source. That stage includes the pinned Rust compiler, the complete
Cargo workspace and lockfile, and the protocol schemas and fixtures embedded
by Rust. Changes to those inputs rebuild the native binary. Ordinary server or
UI changes can reuse it through the existing registry cache (`mode=max`). Each
platform gets its own native build; no cross-architecture binary is reused.
The application build inherits that stage and still runs the normal server
build, including Cargo, binary staging, and generated-contract checks. Rust
input file times are normalized in both stages so fresh checkouts do not force
Cargo to rebuild unchanged source. Changes made by build scripts still reach
Cargo's normal validation. The final application copy excludes Cargo's target
directory as before. Cache misses only cost compilation time.
Pull requests that change the Dockerfile, Docker ignore rules, or Runner native
inputs also build the isolated `runner-build` target in `Docker Runner check`.
This compiles against the actual reduced context and catches missing embedded
inputs before the post-merge image build. It uses a GitHub-hosted runner with
read-only repository access and does not publish images or cache artifacts.

View File

@ -335,3 +335,59 @@ Check:
- [doc/RELEASING.md](RELEASING.md)
- [doc/PUBLISHING.md](PUBLISHING.md)
- [doc/plans/2026-03-17-release-automation-and-versioning.md](plans/2026-03-17-release-automation-and-versioning.md)
## Runner verification dependency cache
`release-verify.yml` caches Cargo dependencies for its `Verify Paperclip Runner`
job using a pinned Rust Cache action. It selects the compiler from the Runner
package's `rust-toolchain.toml` before computing the cache key. Compiler and Cargo
metadata changes select a new cache; the `release-runner-v1` shared key lets
callers of this reusable verification workflow reuse the same dependency cache.
Workspace crates and installed Cargo binaries are excluded. Every run still
builds the Runner workspace and runs `check:all`, including the Rust and
TypeScript tests. Only an own-repository master-push run verifying that push's exact
SHA can restore the cache, and only a successful run saves it. PR, tag, and
manual candidate 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 again.
The trust boundary is the protected master branch, not the cache-key text.
GitHub does not let master restore caches created by a child branch, sibling
branch, tag, or PR merge ref. Both permitted restore scopes (current branch and
default branch) are master here. A workflow with authority to execute arbitrary
code on master can affect verification directly and is already trusted. The
cache contains dependency build artifacts, not credentials or workspace output.
See [GitHub cache access restrictions](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache).
## Chat integration test shards
Release verification runs the large chat integration file on three independent
runners. Five other server shards cover every remaining general server file.
The ordinary local test command and trusted PR workflow keep their complete
`general-server` group. Each chat case shuts down its services, pauses its own
still-active endpoints, and retires its active/waiting conversations after
assertions. This keeps workers in later cases from claiming earlier
fixtures in the shared test database. Application assertions stay unchanged.
Each chat job collects active tests with Vitest, groups cases by source line,
and balances those groups by case count. Parameterized cases and loop-generated
cases on one line stay together. The job re-collects with the exact line filters
it will execute and fails if the selected case identities differ. Hooks and test
execution remain sequential inside each runner with its own temporary home.
Run one shard locally with:
```sh
pnpm test:run:general -- --group general-chat --shard-index 0 --shard-count 3
```
Use indexes 0, 1, and 2 to run the complete chat suite. The CLI validates that
each shard has work and that collection includes usable source locations. A
Vitest collection or filtering change fails verification instead of dropping
tests. Splitting adds three release-verification jobs and repeats collection and
fixture setup; it does not make a single test faster.
The file-duration manifest also records the native Codex Runner integration
suite's measured import and execution cost, so the existing file balancer
accounts for it in both ordinary PR and release verification.

View File

@ -0,0 +1,79 @@
# Storybook branch hosting
The `Storybook Deploy` workflow publishes public static Storybook builds to the
existing private S3 bucket behind CloudFront. It does not deploy to GitHub Pages.
## Current destination
- AWS account: `078455283791`, region `us-east-1`
- Bucket: `paperclipai-runner-e2e-history-078455283791-us-east-1`
- Allowed upload prefix: `storybook/branches/`
- Distribution: `E3GTU28BBO2SFR`
- Public origin: `https://d1p6rlowie26tp.cloudfront.net`
- Role: `arn:aws:iam::078455283791:role/paperclip-storybook-github`
The distribution's default behavior disables edge caching and rewrites directory
URLs to `index.html`. Stable branch indexes send `no-cache`; unique build objects
send `immutable`. No invalidations or CloudFront write permissions are needed.
## GitHub configuration
Create environment `storybook-deploy` with required reviewers set to the
individual CODEOWNERS accounts. Disable administrator bypass, allow self-review,
and allow repository branches. Keep these reviewers synchronized with CODEOWNERS.
The workflow rejects environments with no required reviewers, non-owner reviewers
or administrator bypass enabled. The AWS role trusts only this repository and
this environment, so a branch cannot obtain upload access through an unprotected
environment.
Set repository variables:
| Variable | Value |
| --- | --- |
| `STORYBOOK_AWS_ROLE_ARN` | `arn:aws:iam::078455283791:role/paperclip-storybook-github` |
| `STORYBOOK_AWS_REGION` | `us-east-1` |
| `STORYBOOK_S3_BUCKET` | `paperclipai-runner-e2e-history-078455283791-us-east-1` |
| `STORYBOOK_PUBLIC_BASE_URL` | `https://d1p6rlowie26tp.cloudfront.net` |
No stored AWS access keys are needed. Leave the runner dashboard variables and
GitHub Pages configuration unchanged.
## Operator setup
Use the `paperclip-dev` operator AWS profile. Review the checked-in policies in
`.github/storybook-deploy/` before applying them. The existing GitHub OIDC provider
must be present in this account.
```sh
aws sts get-caller-identity --profile paperclip-dev
aws iam create-role --profile paperclip-dev \
--role-name paperclip-storybook-github \
--assume-role-policy-document file://.github/storybook-deploy/trust-policy.json
aws iam put-role-policy --profile paperclip-dev \
--role-name paperclip-storybook-github --policy-name StorybookBranchUpload \
--policy-document file://.github/storybook-deploy/upload-policy.json
```
For an existing role, use `update-assume-role-policy` instead of `create-role`.
Add the statement from `cloudfront-read-statement.json` to the existing bucket
policy's `Statement` array. Preserve every other statement, including the HTTPS
requirement and runner report access. Keep all S3 public-access blocks enabled;
only CloudFront receives read access to this public-content prefix.
The role has no delete, bucket policy, IAM, CloudFront, or root-object permissions.
The workflow never runs `sync --delete`. Builds accumulate; any retention cleanup
must preserve the build referenced by each branch entry.
## Verification
```sh
node --test scripts/__tests__/storybook-deploy.test.mjs
actionlint .github/workflows/storybook-deploy.yml .github/workflows/storybook-visual.yml
```
Dispatch two source branches, approve each deployment, and check their distinct
branch URLs and each build's `deployment.json`. Redeploy one branch and confirm
its stable URL now points to the new build while the other branch is unchanged.
The publisher checks the public build metadata against the selected source SHA
and verifies that the public branch entry points to this exact build. It retries
brief propagation delays and fails if the branch URL remains stale.

View File

@ -0,0 +1,106 @@
# Cloud build readiness
The `Cloud readiness` workflow starts for every master push. Its versioned
`Cloud deployable v1` job succeeds only after all three prerequisites succeed:
- The existing `Release Verify` workflow checks that exact commit, including
typecheck, builds, general and serialized tests, and Runner verification.
- The reusable `Docker cloud` workflow builds and verifies its Linux AMD64
image, including Sentry resolution and orphan reaping, then publishes the
full-SHA cloud tag. Cloud readiness owns the master trigger so there is one
cloud build per push. Release tags and manual Docker runs retain their callers.
- The full-SHA image and both exact-source npm packages are visible. The
packages are `@paperclipai/shared` and `@paperclipai/db` at
`0.0.0-preview.g<FULL_SHA>`, published through the migrator-only release lane.
Registry metadata must match the full commit, and the database package must
pin the matching shared package.
The Cloud workflow builds the image with `USER_UID=1001` and `USER_GID=1001`,
matching the managed runtime. This avoids a startup user remap, which can walk
the mounted home and delay health checks. Before publishing the full-SHA tag,
the workflow checks the baked identity without running the entrypoint, then
checks the normal entrypoint's effective user and writable home. Volume ownership
repair still runs when needed. The Dockerfile defaults remain `1000:1000` for
self-hosted builds, and runtime identity overrides remain supported. The first
build with the new identity must rebuild layers that depend on the base image;
later builds can reuse those layers.
Verification and image building run concurrently, outside the full npm release's
concurrency group. Different commits have independent groups. Source verification
is initially duplicated with the normal npm release: this spends existing hosted
runner capacity to avoid waiting behind an older release. No verification gate is
removed from npm publication. Watch organization-wide runner queues when measuring
the result.
The artifact wait runs for up to 30 minutes and reports what is missing. Only
an HTTP 404 means publication is pending; authorization errors, upstream outages,
and identity mismatches fail the job. A failed, cancelled, or skipped prerequisite
cannot produce a successful readiness job. Retry the failed publication or build,
then rerun the failed readiness workflow jobs to check the same commit again.
## Consumer contract
`Cloud deployable v1` is a source-and-artifact readiness signal. A deployment
consumer must still resolve and pin the image digest and npm integrity/lockfile,
validate migration contents and compatibility, and apply its target health gates.
The check creates no release record and deploys no instance. A full-SHA tag by
itself, or a successful migrator dispatch, is not this readiness signal.
For automatic selection, accept only a successful job named exactly
`Cloud deployable v1` in the latest attempt of a successful
`.github/workflows/cloud-readiness.yml` run in `paperclipai/paperclip`, with
event `push`, head branch `master`, and the expected full head SHA and repository.
Do not trust a similarly named check from another workflow or a manual branch run.
Order candidates by master ancestry, not job completion time: an older commit
finishing late must not roll a fleet backward. Fail closed on API errors.
Existing npm canary discovery is unchanged by this producer workflow. Consumers
can adopt the versioned signal separately after the workflow has landed and
successfully verified a real master commit.
## Timing and rollout
The reusable Runner chaos workflow scopes concurrency to the caller workflow
and source ref. Cloud readiness and the npm release can verify the same commit
at the same time. They must not cancel each other's required test job.
Measure the complete path from a master merge to a healthy target running that
exact commit. Keep readiness and deployment as separate milestones:
| Milestone | Evidence | Elapsed time starts at |
| --- | --- | --- |
| Merge | Merged PR timestamp and full merge commit SHA | Merge |
| Image available | Successful full-SHA image publication and verification | Merge |
| Cloud deployable | Successful `Cloud deployable v1` job in the accepted push run and attempt | Merge |
| Canary healthy | Deployment consumer's canary health gate confirms the target commit | Merge |
| Fleet complete | Campaign succeeds for all eligible targets at that commit | Merge |
Record the source SHA, workflow run ID and attempt, readiness job completion
time, and deployment campaign identity together. Verify the run against the
consumer contract above. A manual dispatch can test wiring, but its timestamp
does not measure automatic merge-to-deploy latency. A preparation-only run
resolves artifacts without deploying a target and must not be counted as a
successful deployment.
Record queue time and the image, source-verification, and artifact-wait durations
separately. The slowest prerequisite determines readiness; shortening an already
faster prerequisite may have no effect on the total. After readiness, measure
consumer discovery delay, artifact resolution, canary health, and fleet rollout.
An automatic consumer that still waits for the full npm canary publication has
that queue on its critical path even if cloud artifacts are ready earlier.
For a target health measurement, confirm the deployed source SHA as well as
service health. A proxy health response alone may describe the control plane
while the tenant still runs the previous image. Report the eligible target count,
excluded or sleeping targets, retries, and failures with the fleet result. Record
runner queue conditions and cache state; one warm or cold run is a sample, not a
latency guarantee.
Land full-SHA image publication, independent cloud builds, and migrator-only
publication before enabling this workflow. Until those producers are present,
the artifact wait cannot succeed. A manual dispatch on master can verify the
wiring, but automatic consumers should use push runs. Source verification and
registry checks can be rerun without deploying or changing mutable npm channels.
When reverting this workflow, restore the master push trigger in
`docker-cloud.yml` in the same change so master images continue to build.

42
doc/cloud-ui-snippet.md Normal file
View File

@ -0,0 +1,42 @@
# Cloud UI snippet
Cloud operators can set `PAPERCLIP_CLOUD_UI_SNIPPET` to an HTML snippet.
The server inserts it before `</body>` in static and Vite-served UI pages.
It requires the existing Cloud-managed instance signal. Self-hosted instances
ignore this setting. No snippet is enabled by default.
This is trusted deployment configuration, not user input. It executes in the
application origin and is visible to every browser that receives the UI shell.
Do not include secrets or customer data. Restart the app after changing it.
Operators must review scripts and any required CSP changes before deployment.
## Plain closed beta
Set the value to this standard embed, replacing `YOUR_CHAT_APP_ID` with the
public chat app ID for the target environment:
```html
<script>
(function(d) {
var script = d.createElement('script');
script.src = 'https://chat.cdn-plain.com/index.js';
script.onload = function() { Plain.init({ appId: 'YOUR_CHAT_APP_ID' }); };
d.head.appendChild(script);
})(document);
</script>
```
No signing secret or Plain API key is required. No Paperclip customer identity
or organization data is passed. Plain manages the anonymous browser session;
there is no Paperclip account-switch integration. Ask users for identifying
information when needed. The existing feedback flag remains unchanged.
Docs: [Plain chat](https://www.plain.com/docs/product/channels/chat).
## Verification and rollback
On staging, open `/`, `/index.html`, and an organization dashboard directly.
Confirm the bubble appears and a test message reaches Plain. Verify the support
reply returns. On a self-hosted instance, confirm no snippet or widget is loaded.
Unset the snippet and restart to remove it on the next page load. Existing open
tabs retain the widget until refreshed. No production deployment is implied.

View File

@ -552,6 +552,8 @@ Recovery rule:
This is an active-work continuity recovery.
After a productive successful run, recovery checks that the issue is still `in_progress` and assigned to the same agent under the enqueue transaction's issue lock. The sweep's earlier snapshot cannot authorize a continuation after completion, cancellation, reassignment, or a move to another status. A mismatch records a skipped wake receipt without creating a run. An empty queued continuation cancelled because the issue became terminal is omitted from task chat; its cancellation remains in the run log. Runs that actually started still show their stop state.
The same bounded rule applies when the previous heartbeat reported waiting on a local/background watcher and that watcher was killed, disappeared, or was never represented by a durable Paperclip primitive. Paperclip queues at most one continuation for the same recovery fingerprint. If the continuation also leaves only local watcher evidence, Paperclip must surface a real blocker or explicit recovery action instead of repeating continuation recovery. A new monitor, scheduled wake, healthy delegated blocker issue, or other durable source mutation resolves that recovery fingerprint normally.
#### Deliberate wait is not a lost run

View File

@ -43,6 +43,31 @@ version 1, request ID, SHA, stage `build`, and status `ready`. It expires after
## Publishing configuration and isolation
### Migrator publication on merge
The `Cloud artifacts` workflow starts a `cloud-migrator` dispatch of `release.yml`
for every push to `master`. This dispatch builds and publishes only the exact-source
`@paperclipai/shared` and `@paperclipai/db` preview packages. It starts independently
of the full npm release and does not wait for the Docker image. The normal Docker
workflow supplies the image separately.
The run title is `Cloud migrator <FULL_SHA>`. A successful `Cloud artifacts`
dispatch job only confirms that GitHub accepted the request. Inspect the matching
`release.yml` run to confirm publication completed. This path does not produce a
`stack-deploy-result` or certify source-test success or deployment readiness.
Cloud must still verify all deployment prerequisites.
To retry one commit, dispatch `release.yml` on `master` with `channel=cloud-migrator`,
the full SHA as `source_ref`, a new UUID v4 as `request_id`, and `dry_run=false`.
`preview_migrator` is not required for this channel. Existing packages are verified
and reused. Preview and migrator-only runs use separate workflow concurrency
groups. Only their package publication jobs share a group for the same SHA, so
they cannot publish the same version concurrently and the migrator does not wait
for a preview's image build. Different SHAs publish in separate groups; the full
release keeps its existing group.
### Publisher identity
Configure npm trusted publishing for **both packages** with repository
`paperclipai/paperclip`, workflow `release.yml`, and environment `npm-canary`.
The image publisher uses the same environment, whose deployment branch policy

View File

@ -18,6 +18,14 @@ export const ACPX_HANDSHAKE_TIMEOUT_MS = 60_000;
// of a channel loss.
export const ACPX_HANDSHAKE_TRANSPORT_POLL_MS = 250;
// The bound on how long the host waits, after a latched terminal sandbox
// duplex-channel loss, for the agent to answer the `turn.cancel()` request.
// `cancel()` only asks the agent to end the turn; it does not end the turn by
// itself. An agent that stopped answering never honors it, so this deadline
// is the host-side bound that ends the run without the agent's help. It is
// much smaller than the whole-adapter execution timeout.
export const ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS = 30_000;
export const ACPX_ADAPTER_AGENT_IDS = {
claude_local: "claude",
codex_local: "codex",

View File

@ -6129,6 +6129,7 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
let lossOrdered = false;
let lossReason: string | null = null;
let completionOrdered = false;
let lossListener: ((reason: string) => void) | null = null;
const readDisposition = () => ({ failed: lossOrdered, lossReason });
const markOrderlyCompletion = vi.fn(() => {
if (completionOrdered || lossOrdered) return;
@ -6138,6 +6139,12 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
markOrderlyCompletion();
return readDisposition();
});
const onLoss = vi.fn((listener: (reason: string) => void) => {
lossListener = listener;
return () => {
if (lossListener === listener) lossListener = null;
};
});
const stop = vi.fn(async () => {});
const handle = {
env: {
@ -6148,19 +6155,25 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
readRunDisposition: () => readDisposition(),
settleRunDisposition,
markOrderlyCompletion,
onLoss,
stop,
};
return {
handle,
markOrderlyCompletion,
settleRunDisposition,
onLoss,
readDisposition,
// Record the first ordered loss. A loss ordered after a completion, or a
// second loss, is a no-op — the same rule the real transport applies.
// A loss that latches here (the first ordered call) also pushes the
// reason to the one registered listener, the same way the real HTTP/2
// transport's disposition latch does.
emitLoss: (reason: string) => {
if (lossOrdered || completionOrdered) return;
lossOrdered = true;
lossReason = reason;
lossListener?.(reason);
},
};
}
@ -6215,6 +6228,81 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
};
}
// A runtime whose one turn never resolves on its own — it hangs exactly
// like a turn whose sandbox duplex channel died mid-turn produces no
// terminal result. The turn only ends when something calls `cancel()`, the
// same mechanism the push seam calls. `onCancel` observes each call.
function hangingTurnRuntime(onCancel: (reason: string | undefined) => void) {
let release: (() => void) | null = null;
const released = new Promise<void>((resolve) => {
release = resolve;
});
return {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
await released;
})(),
result: (async () => {
await released;
return { status: "cancelled" as const, stopReason: "cancelled" };
})(),
cancel: async (input?: { reason?: string }) => {
onCancel(input?.reason);
release?.();
},
}),
setConfigOption: async () => {},
close: async () => {},
};
}
// A runtime whose one turn hangs exactly like the real `acpx` shape: its
// `cancel()` only sends the cancel request and returns. It does NOT settle
// the turn — neither `events` nor `result` ever resolves on its own.
// `closeStream()` ends the event drain locally, with no agent cooperation,
// the same way the real runtime's does; it still leaves `result` pending.
// This is the sensitivity control for the fail-fast deadline: only the
// deadline, not the cancel request, can end this turn.
function unresponsiveCancelTurnRuntime(input: {
onCancel: (reason: string | undefined) => void;
onCloseStream: (reason: string | undefined) => void;
}) {
let endEvents: (() => void) | null = null;
const eventsEnded = new Promise<void>((resolve) => {
endEvents = resolve;
});
return {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
await eventsEnded;
})(),
// Never settles on its own. The real acpx result settles only when
// the provider process returns or rejects, bounded by the adapter
// execution timeout — not by a `session/cancel` request.
result: new Promise<never>(() => {}),
cancel: async (reasonInput?: { reason?: string }) => {
input.onCancel(reasonInput?.reason);
},
closeStream: async (reasonInput?: { reason?: string }) => {
input.onCloseStream(reasonInput?.reason);
endEvents?.();
},
}),
setConfigOption: async () => {},
close: async () => {},
};
}
async function setupRemoteSandbox() {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@ -6238,6 +6326,7 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
handle: unknown,
runtime: unknown,
sandbox: Awaited<ReturnType<typeof setupRemoteSandbox>>,
deps: Partial<AcpxEngineExecutorOptions> = {},
) {
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(
async () => handle as never,
@ -6247,6 +6336,7 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
);
const execute = createAcpxEngineExecutor({
createRuntime: () => runtime as never,
...deps,
});
return await execute({
runId: "run-duplex-seam",
@ -6342,6 +6432,25 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
expect(fake.readDisposition().failed).toBe(false);
});
it("keeps duplex_channel_lost precedence when the loss latches before a failed terminal", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
// Latch the loss before the ACP terminal resolves, and the terminal
// itself also reports a provider failure.
const runtime = runtimeWithFailedResult(() => fake.emitLoss("provider_exit"));
const result = await runRemote(fake.handle, runtime, sandbox);
expect(result.exitCode).not.toBe(0);
// The duplex loss reason wins over the provider's own failed terminal.
expect(result.errorCode).toBe("duplex_channel_lost");
// The message carries only the typed loss reason, not the raw provider
// failure text.
expect(result.errorMessage).toContain("provider_exit");
expect(result.errorMessage).not.toContain("agent failed");
expect(result.resultJson).toMatchObject({ status: "failed" });
});
it("releases the runtime locally and places no remote close call once the duplex channel is lost", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
@ -6435,6 +6544,170 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
expect(result.errorCode).not.toBe("acpx_session_init_failed");
expect(result.errorCode).not.toBe("acpx_handshake_timeout");
}, 10000);
it("aborts an in-flight turn and fails the run when the duplex channel latches a loss mid-turn", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
const cancelReasons: (string | undefined)[] = [];
// This turn never returns a terminal result on its own: without a push
// seam it would wait for the wall-clock adapter execution timeout. It
// ends only once something calls `cancel()`.
const runtime = hangingTurnRuntime((reason) => cancelReasons.push(reason));
const resultPromise = runRemote(fake.handle, runtime, sandbox);
// Wait until the turn registers its loss listener, then latch the loss —
// the same order a real mid-turn channel death follows: the turn starts,
// then later the channel is lost.
await vi.waitFor(() => expect(fake.onLoss).toHaveBeenCalled());
fake.emitLoss("provider_exit");
const result = await resultPromise;
// The push seam cancelled the hanging turn instead of waiting for the
// turn to return a terminal result on its own, so the run ends promptly
// instead of waiting for the adapter execution timeout.
expect(cancelReasons).toHaveLength(1);
expect(result.exitCode).not.toBe(0);
expect(result.errorCode).toBe("duplex_channel_lost");
// The failure message carries only the closed loss-reason enum, never
// raw provider text.
expect(result.errorMessage).toContain("provider_exit");
expect(result.resultJson).toMatchObject({ status: "failed" });
}, 5000);
it("bounds the wait with a deadline when a latched loss cancel gets no agent cooperation", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
const cancelReasons: (string | undefined)[] = [];
const closeStreamReasons: (string | undefined)[] = [];
// The real acpx shape: `cancel()` only requests cancellation and returns.
// It settles neither `events` nor `result`. Only the fail-fast deadline
// can end this turn.
const runtime = unresponsiveCancelTurnRuntime({
onCancel: (reason) => cancelReasons.push(reason),
onCloseStream: (reason) => closeStreamReasons.push(reason),
});
const resultPromise = runRemote(fake.handle, runtime, sandbox, {
// Small and fake-time-free: real-timer test, so the deadline must stay
// short enough to run fast without waiting 60 real seconds.
duplexLossCancelDeadlineMs: 25,
});
await vi.waitFor(() => expect(fake.onLoss).toHaveBeenCalled());
fake.emitLoss("provider_exit");
const result = await resultPromise;
// The seam still tried the cooperative cancel first.
expect(cancelReasons).toHaveLength(1);
// The agent never answered the cancel, so the deadline ended the event
// drain locally instead of waiting for it.
expect(closeStreamReasons).toHaveLength(1);
// The run reached a failure terminal within the deadline, even though
// neither `events` nor `result` ever settled on their own.
expect(result.exitCode).not.toBe(0);
expect(result.errorCode).toBe("duplex_channel_lost");
// The failure message carries only the closed loss-reason enum, never
// raw provider text.
expect(result.errorMessage).toContain("provider_exit");
expect(result.resultJson).toMatchObject({ status: "failed" });
}, 5000);
it("awaits stream closure and the event drain before finalizing a duplex loss deadline", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
let endEvents: (() => void) | null = null;
const eventsEnded = new Promise<void>((resolve) => {
endEvents = resolve;
});
let releaseCloseStream!: () => void;
const closeStreamGate = new Promise<void>((resolve) => {
releaseCloseStream = resolve;
});
let closeStreamCalls = 0;
// `closeStream()` stays pending on a gate the test controls, and only
// ends the event drain once the test releases that gate. If the run
// finalizes before the gate opens, the seam did not wait for the close
// call, so a late event on this drain could still land after the result.
const runtime = {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
await eventsEnded;
})(),
result: new Promise<never>(() => {}),
cancel: async () => {},
closeStream: async () => {
closeStreamCalls += 1;
await closeStreamGate;
endEvents?.();
},
}),
setConfigOption: async () => {},
close: async () => {},
};
const resultPromise = runRemote(fake.handle, runtime, sandbox, {
duplexLossCancelDeadlineMs: 25,
});
await vi.waitFor(() => expect(fake.onLoss).toHaveBeenCalled());
fake.emitLoss("provider_exit");
await vi.waitFor(() => expect(closeStreamCalls).toBe(1));
// The close call has not resolved yet, so the run must still be pending.
let settled = false;
void resultPromise.then(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(settled).toBe(false);
releaseCloseStream();
const result = await resultPromise;
expect(result.errorCode).toBe("duplex_channel_lost");
}, 5000);
it("does not abort or fail an already-completed run when the duplex channel loses after an orderly completion", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
const cancelReasons: (string | undefined)[] = [];
const runtime = {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed" as const, stopReason: "end_turn" }),
cancel: async (input?: { reason?: string }) => {
cancelReasons.push(input?.reason);
},
}),
setConfigOption: async () => {},
close: async () => {},
};
const result = await runRemote(fake.handle, runtime, sandbox);
expect(result.exitCode).toBe(0);
expect(result.errorCode ?? null).toBeNull();
// The channel dies only after the turn already completed cleanly. The
// loss listener the turn registered is still live at this point, but the
// latch already marked the orderly completion, so the loss cannot relatch
// and must never reach a cancel call on the (already-finished) turn.
fake.emitLoss("provider_exit");
expect(cancelReasons).toHaveLength(0);
expect(fake.readDisposition().failed).toBe(false);
});
});
describe("ACPX startup handshake guard and late-completion fence", () => {

View File

@ -92,6 +92,7 @@ import {
type AcpSessionStore,
} from "acpx/runtime";
import {
ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS,
ACPX_HANDSHAKE_TIMEOUT_MS,
ACPX_HANDSHAKE_TRANSPORT_POLL_MS,
DEFAULT_ACP_ENGINE_AGENT,
@ -342,6 +343,14 @@ export interface AcpxRemoteManagedHomeResult {
export interface AcpxEngineExecutorOptions {
createRuntime?: AcpxRuntimeFactory;
now?: () => number;
/**
* The bound on how long the fail-fast seam waits for a cooperative
* `turn.cancel()` after a latched terminal sandbox duplex-channel loss,
* before it ends the turn without the agent's help. Defaults to
* {@link ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS}. Tests inject a small value
* to drive the deadline without real time.
*/
duplexLossCancelDeadlineMs?: number;
warmHandles?: Map<string, RuntimeCacheEntry>;
/**
* Per-session staged-runtime cache for the remote runner-backed lane (PR 3).
@ -3704,6 +3713,7 @@ function openTurnSpan(
export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
const createRuntime = deps.createRuntime ?? createAcpRuntime;
const now = deps.now ?? (() => Date.now());
const duplexLossCancelDeadlineMs = deps.duplexLossCancelDeadlineMs ?? ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS;
const warmHandles = deps.warmHandles ?? defaultWarmHandles;
const stagedRuntimes = deps.stagedRuntimes ?? defaultStagedRuntimes;
const stagingLocks = deps.stagingLocks ?? defaultStagingLocks;
@ -3800,6 +3810,24 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
let releaseStagingLease: (() => void) | null = null;
let stopTimer: ReturnType<typeof setTimeout> | undefined;
let removeStopListener: (() => void) | undefined;
// Unregisters the sandbox duplex bridge's loss listener (below, in
// `stepTurnStart`). Set only on a sandbox target whose bridge exposes
// `onLoss`; stays undefined everywhere else, so the cleanup call is a
// no-op there.
let removeLossListener: (() => void) | undefined;
// Bounds the wait after a latched terminal duplex loss so a silent agent
// cannot hold the run open on the cooperative `turn.cancel()` request
// alone. `stepTurnStart` arms `lossDeadlineTimer` the moment a loss
// latches; it stays undefined everywhere else, so the cleanup call below
// is a no-op there. `stepEventRelay` races the turn against
// `lossDeadline` and, once it fires, ends the event drain and hands
// `turnFinalize` a host-built terminal instead of the agent's.
let lossDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
let lossDeadlineTripped = false;
let resolveLossDeadline: (() => void) | undefined;
const lossDeadline = new Promise<void>((resolve) => {
resolveLossDeadline = resolve;
});
let forcedStop = false;
let runtimeStopConfirmed = false;
let safeInterruptedSession = false;
@ -4606,6 +4634,36 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
signal,
});
activeTurn = turn;
// A latched sandbox duplex-channel loss otherwise has no way to reach
// this turn: the bridge only exposes a pull read, and the engine
// pulls it at the terminal-finalization boundary, which runs only
// after the turn already returned a terminal result. A channel that
// dies mid-turn then leaves the turn with no terminal result to
// return, so it waits for the wall-clock adapter execution timeout
// instead of failing fast. Cancel the turn the moment a terminal loss
// latches — whether it latches from here on, or already latched
// before this turn started — so the turn returns a terminal result
// right away. `turnFinalize` reads the same latch and builds the
// failure from the typed loss reason alone.
const bridge = prepared.paperclipBridge;
if (bridge?.onLoss) {
const cancelForLoss = (reason: DuplexLossReason) => {
void turn.cancel({ reason: `paperclip sandbox duplex channel lost (${reason})` }).catch(() => {});
// `cancel()` only asks the agent to end the turn; it does not end
// the turn by itself. Start the fail-fast deadline the moment the
// loss latches, so the run does not wait past this bound for an
// agent that stopped answering.
if (!lossDeadlineTimer && !lossDeadlineTripped) {
lossDeadlineTimer = setTimeout(() => {
lossDeadlineTripped = true;
resolveLossDeadline?.();
}, duplexLossCancelDeadlineMs);
}
};
removeLossListener = bridge.onLoss(cancelForLoss);
const alreadyLatched = bridge.readRunDisposition?.();
if (alreadyLatched?.failed) cancelForLoss(alreadyLatched.lossReason ?? "other");
}
// ACP can resolve the turn before its provider exits. Keep the Stop
// deadline armed through settlement, including provider cleanup.
const armStopDeadline = () => {
@ -4631,40 +4689,74 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
},
};
};
// The host-built terminal `stepEventRelay` hands to `turnFinalize` once
// the fail-fast deadline fires with no agent-supplied terminal. Its
// `status` mirrors the shape a real cooperative cancel already
// produces, so `turnFinalize` needs no change: it reads the latched
// loss disposition, not this `stopReason`, to build the reported
// failure and its message.
const LOSS_DEADLINE_TERMINAL: AcpRuntimeTurnResult = {
status: "cancelled",
stopReason: "paperclip_duplex_loss_deadline",
};
const stepEventRelay = async (): Promise<AcpRuntimeTurnResult> => {
const turn = activeTurn as AcpRuntimeTurn;
const toolTitles = new Map<string, string>();
for await (const event of turn.events) {
// ACPX currently flattens client-side filesystem/terminal receipts
// into status text. They cannot establish complete action outcomes.
if (event.type === "status" && /^(fs|terminal)\//.test(event.text)) incompleteToolInventory = true;
if (event.type === "tool_call") {
if (!event.toolCallId) incompleteToolInventory = true;
else {
const previous = interruptionTools.get(event.toolCallId);
interruptionTools.set(event.toolCallId, {
kind: event.kind ?? previous?.kind,
status: event.status ?? previous?.status,
});
const drainEvents = (async (): Promise<void> => {
for await (const event of turn.events) {
// ACPX currently flattens client-side filesystem/terminal receipts
// into status text. They cannot establish complete action outcomes.
if (event.type === "status" && /^(fs|terminal)\//.test(event.text)) incompleteToolInventory = true;
if (event.type === "tool_call") {
if (!event.toolCallId) incompleteToolInventory = true;
else {
const previous = interruptionTools.get(event.toolCallId);
interruptionTools.set(event.toolCallId, {
kind: event.kind ?? previous?.kind,
status: event.status ?? previous?.status,
});
}
}
if (event.type === "text_delta" && event.stream !== "thought") {
currentOutputChunk.push(event.text);
} else if (event.type === "tool_call" && event.tag !== "tool_call_update") {
// ACP makes tool-call status optional. The normalized event tag is
// the reliable boundary between an initial call and its updates,
// so a statusless initial call must still end the preceding output
// segment while updates must not create extra boundaries.
flushOutputSegment();
}
if (event.type === "status" && event.tag === "usage_update") {
eventBreakdown = event.breakdown ?? eventBreakdown;
eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd;
}
await emitRuntimeEvent(ctx, event, toolTitles, prepared.coalescePlaceholderToolUpdates);
}
if (event.type === "text_delta" && event.stream !== "thought") {
currentOutputChunk.push(event.text);
} else if (event.type === "tool_call" && event.tag !== "tool_call_update") {
// ACP makes tool-call status optional. The normalized event tag is
// the reliable boundary between an initial call and its updates,
// so a statusless initial call must still end the preceding output
// segment while updates must not create extra boundaries.
flushOutputSegment();
}
if (event.type === "status" && event.tag === "usage_update") {
eventBreakdown = event.breakdown ?? eventBreakdown;
eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd;
}
await emitRuntimeEvent(ctx, event, toolTitles, prepared.coalescePlaceholderToolUpdates);
})();
// A latched loss already asked the agent to cancel (above, in
// `cancelForLoss`); that request settles neither `turn.events` nor
// `turn.result` by itself. Race the event drain against the fail-fast
// deadline so a silent agent cannot hold this wait open.
const eventsEnded = await Promise.race([
drainEvents.then(() => true as const),
lossDeadline.then(() => false as const),
]);
if (!eventsEnded) {
// The deadline won: stop waiting on the agent. `closeStream` ends
// the event drain locally, with no agent cooperation required. Await
// both the close call and the drain it unblocks before this step
// returns, so no late runtime event can still mutate shared state
// (output segments, tool inventory) after finalization reads it.
await turn.closeStream({ reason: "paperclip duplex loss cancel deadline" }).catch(() => {});
await drainEvents.catch(() => {});
flushOutputSegment();
return LOSS_DEADLINE_TERMINAL;
}
flushOutputSegment();
return await turn.result;
// `turn.result` settles only when the agent's provider process
// returns or rejects; a latched loss that armed the deadline after
// the event drain already ended must still bound this wait.
return await Promise.race([turn.result, lossDeadline.then(() => LOSS_DEADLINE_TERMINAL)]);
};
const stepTurnFinalize = async (
input: TurnFinalizeInput<AcpRuntimeTurnResult>,
@ -4673,33 +4765,23 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
const terminal = input.terminal;
const timedOut = input.timedOut;
// Read the sandbox duplex control-channel disposition at the ACP
// terminal-finalization boundary, before the bridge teardown. A control
// channel that died mid-turn latches a failure with a typed loss reason;
// a healthy channel or a normal-teardown loss reports a success. Only a
// nominally completed, non-timed-out terminal is success-eligible, so the
// seam reads the disposition only there. For that success-eligible
// terminal the seam marks the host-observed orderly completion, so a later
// teardown loss cannot flip the run to a failure. The file bridge path
// never sets these methods, so the optional calls no-op there.
// terminal-finalization boundary, before the bridge teardown, on every
// terminal outcome. A control channel that died before this point
// latches a failure with a typed loss reason; a healthy channel or a
// normal-teardown loss reports a success. The read and the mark of the
// host-observed orderly completion happen atomically in one broker
// step, with no `await` between them, so a teardown loss cannot slip
// in between. This stops a later teardown `channel_exit` from latching
// a false loss. The mark no-ops once a loss already latched, so a real
// mid-turn loss still fails the run — including a loss that arrived
// through the in-flight-turn cancel this seam issues, which surfaces
// here as a `cancelled` (not `completed`) terminal, not just through a
// nominally completed terminal. The file bridge path never sets this
// method, so the optional call no-ops there.
let duplexLossReason: DuplexLossReason | null = null;
if (terminal.status === "completed" && !timedOut) {
// Success-eligible terminal. Atomically read the disposition and mark
// the orderly completion in one broker step. No `await` separates the
// read from the mark, so a teardown loss cannot slip in between them. A
// latched loss fails the run closed; a healthy channel marks its
// orderly completion, so a later teardown loss stays a normal teardown.
const disposition = prepared.paperclipBridge?.settleRunDisposition?.() ?? null;
if (disposition?.failed) {
duplexLossReason = disposition.lossReason ?? "other";
}
} else {
// Non-success-eligible terminal (failed, cancelled, or timed out). A
// deliberate host teardown follows, so mark the orderly completion now.
// This stops the teardown `channel_exit` from latching `lossSeq`, from
// emitting a false loss event, and from incrementing the loss counters.
// The mark no-ops once a loss latched, so a real mid-run loss still
// fails the run.
prepared.paperclipBridge?.markOrderlyCompletion?.();
const disposition = prepared.paperclipBridge?.settleRunDisposition?.() ?? null;
if (disposition?.failed) {
duplexLossReason = disposition.lossReason ?? "other";
}
// A terminal that reports "completed" but whose duplex control channel
// died before the completion is not a success. The seam fails it closed.
@ -4778,12 +4860,12 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
signal: timedOut ? "SIGTERM" : null,
timedOut,
errorMessage,
errorCode: terminal.status === "failed"
? "acpx_turn_failed"
: timedOut
? "acpx_timeout"
: channelLost
? DUPLEX_CHANNEL_LOST_ERROR_CODE
errorCode: timedOut
? "acpx_timeout"
: channelLost
? DUPLEX_CHANNEL_LOST_ERROR_CODE
: terminal.status === "failed"
? "acpx_turn_failed"
: null,
sessionId: sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
@ -4831,16 +4913,6 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
resources: emptyConsumed,
};
}
if (terminal.status === "failed") {
return {
kind: "failed",
cause: {
kind: "turn_failed",
error: terminal.error instanceof Error ? terminal.error : new Error(String(terminal.error)),
},
resources: emptyConsumed,
};
}
if (terminal.status === "cancelled") {
return {
kind: "cancelled",
@ -4848,10 +4920,12 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
resources: emptyConsumed,
};
}
// A completed terminal whose duplex control channel died mid-turn returns
// a failed completion, so the coordinator settles for a failure and the
// reuse decision forbids a save. The message carries only the typed loss
// reason, so no raw provider text rides the cause.
// A duplex control-channel loss outranks a provider-reported failure or
// completion: the loss reason explains why the provider terminal reads
// the way it does, not the other way round. This also covers a
// "completed" terminal whose channel died mid-turn. The message carries
// only the typed loss reason, so no raw provider text rides the cause,
// even when the provider terminal itself reports `failed`.
if (channelLost) {
return {
kind: "failed",
@ -4862,6 +4936,16 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
resources: emptyConsumed,
};
}
if (terminal.status === "failed") {
return {
kind: "failed",
cause: {
kind: "turn_failed",
error: terminal.error instanceof Error ? terminal.error : new Error(String(terminal.error)),
},
resources: emptyConsumed,
};
}
return { kind: "finalized" };
}
const err = input.error;
@ -5195,6 +5279,8 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
} finally {
clearTimeout(stopTimer);
removeStopListener?.();
removeLossListener?.();
clearTimeout(lossDeadlineTimer);
// End the run root span exactly once, on every return and on a throw.
runRootSpan.end(runFailed);
// Release the per-session staging lease as the run's final act, AFTER the

View File

@ -344,6 +344,20 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle {
* bridge path never sets it, so the method is absent there.
*/
markOrderlyCompletion?(): void;
/**
* Register a listener for a newly latched terminal loss. The listener
* fires at most once, and only for a loss that flips the disposition to
* failed never for a clean channel end that orders after a
* host-observed orderly completion. Returns a function that unregisters
* the listener.
*
* The caller uses this to abort an in-flight Agent Client Protocol turn
* the moment the channel dies, instead of waiting for the turn to return
* a terminal result on its own (a dead channel can leave a turn with
* nothing to return). The file bridge path never sets it, so the method
* is absent there.
*/
onLoss?(listener: (reason: DuplexLossReason) => void): () => void;
stop(): Promise<void>;
}
@ -3661,12 +3675,19 @@ interface Http2RunDispositionLatch {
markOrderlyCompletion(): void;
/** Atomically mark the orderly completion and read the disposition. */
settleRunDisposition(): DuplexBrokerRunDisposition;
/**
* Register a listener that fires once, only on the call to `recordLoss`
* that actually latches a new terminal loss. Returns a function that
* unregisters the listener.
*/
onLoss(listener: (reason: DuplexLossReason) => void): () => void;
}
function createHttp2RunDispositionLatch(): Http2RunDispositionLatch {
let lossOrdered = false;
let lossReason: DuplexLossReason | null = null;
let completionOrdered = false;
let lossListener: ((reason: DuplexLossReason) => void) | null = null;
const markOrderlyCompletion = (): void => {
if (completionOrdered || lossOrdered) return;
completionOrdered = true;
@ -3679,6 +3700,7 @@ function createHttp2RunDispositionLatch(): Http2RunDispositionLatch {
if (lossOrdered || completionOrdered) return false;
lossOrdered = true;
lossReason = reason;
lossListener?.(reason);
return true;
},
markOrderlyCompletion,
@ -3686,6 +3708,12 @@ function createHttp2RunDispositionLatch(): Http2RunDispositionLatch {
markOrderlyCompletion();
return { failed: lossOrdered, lossReason };
},
onLoss(listener: (reason: DuplexLossReason) => void): () => void {
lossListener = listener;
return () => {
if (lossListener === listener) lossListener = null;
};
},
};
}
@ -4672,6 +4700,8 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// the mark and a teardown loss cannot slip in between.
settleRunDisposition: (): DuplexBrokerRunDisposition => dispositionLatch.settleRunDisposition(),
markOrderlyCompletion: (): void => dispositionLatch.markOrderlyCompletion(),
onLoss: (listener: (reason: DuplexLossReason) => void): (() => void) =>
dispositionLatch.onLoss(listener),
stop: async () => {
// Close the HTTP/2 server's sessions, then the channel, before
// lease release, so no live provider session remains when the

View File

@ -5812,7 +5812,9 @@ fn durable_descendant_lineage_survives_capacity_and_provider_restoration() {
json!({"text": "Read test context."}),
))
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
// Persisting 300 descendant notifications can exceed five seconds while
// the other provider tests contend for disk and CPU on a shared runner.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut completed = false;
let mut children = std::collections::BTreeSet::new();
while std::time::Instant::now() < deadline && !completed {
@ -5830,7 +5832,11 @@ fn durable_descendant_lineage_survives_capacity_and_provider_restoration() {
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(completed);
assert!(
completed,
"descendant run did not complete; observed {} of 300 children",
children.len()
);
assert_eq!(children.len(), 300);
first.shutdown().unwrap();
drop(first);

View File

@ -14,6 +14,27 @@ function readWorkflow(name) {
return readFileSync(path.join(repoRoot, ".github/workflows", name), "utf8");
}
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];
assert.ok(group, "chaos verification must define its concurrency group");
// GitHub supplies the top-level caller's workflow name to reusable calls.
const resolveGroup = (caller, ref) => group
.replaceAll("${{ github.workflow }}", readWorkflow(caller).match(/^name: (.+)$/m)[1])
.replaceAll("${{ inputs.ref || github.ref }}", ref)
.toLowerCase();
const sha = "a".repeat(40);
const callers = ["cloud-readiness.yml", "release.yml", "runner-chaos-evals.yml"];
const groups = callers.map((caller) => resolveGroup(caller, sha));
assert.equal(new Set(groups).size, callers.length,
"Cloud readiness, Release, and standalone evals must not cancel each other");
assert.ok(groups.every((value) => !value.includes("${{")), "resolve every group input");
assert.notEqual(resolveGroup("cloud-readiness.yml", sha),
resolveGroup("cloud-readiness.yml", "b".repeat(40)), "different sources remain independent");
assert.match(chaosWorkflow, /cancel-in-progress: true/);
});
test("release workflow delegates stable and canary verification to the reusable workflow", () => {
const releaseWorkflow = readWorkflow("release.yml");
@ -206,28 +227,16 @@ test("release verify workflow covers the same split test surface as stable PR ve
assert.match(buildJob, /persist-credentials: false/);
assert.doesNotMatch(buildJob, /cache: pnpm/);
for (const group of [
"general-server",
"general-workspaces-a",
"general-workspaces-b",
]) {
for (const group of ["general-server-without-chat", "general-chat", "general-workspaces-a", "general-workspaces-b"]) {
assert.match(verifyWorkflow, new RegExp(`group: ${group}`));
}
for (const shardIndex of [0, 1, 2]) {
assert.match(
verifyWorkflow,
new RegExp(
`group: general-server[\\s\\S]*?shard_index: ${shardIndex}[\\s\\S]*?shard_count: 3`,
),
);
for (const [group, count] of [["general-server-without-chat", 5], ["general-chat", 3]]) {
const rows = [...verifyWorkflow.matchAll(new RegExp(`group: ${group}\\n\\s+group_label: [^\\n]+\\n\\s+shard_index: (\\d+)\\n\\s+shard_count: (\\d+)`, "g"))];
assert.deepEqual(rows.map((row) => [Number(row[1]), Number(row[2])]),
Array.from({ length: count }, (_, index) => [index, count]));
}
for (const shardIndex of [0, 1, 2, 3, 4]) {
assert.match(
verifyWorkflow,
new RegExp(`shard_index: ${shardIndex}[\\s\\S]*?shard_count: 5`),
);
assert.match(verifyWorkflow, new RegExp(`shard_index: ${shardIndex}[\\s\\S]*?shard_count: 5`));
}
// workspaces-a splits with Vitest native --shard in pr.yml; release

View File

@ -10,6 +10,8 @@ import {
partitionGeneralServerSuites,
} from "../general-server-shard.mjs";
import { assertSelectedTests, partitionTestLines } from "../test-line-shard.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const script = path.join(repoRoot, "scripts", "run-vitest-stable.mjs");
const durationsManifest = path.join(repoRoot, "scripts", "general-server-shard-durations.json");
@ -264,3 +266,53 @@ test("the real shard partition is duration-balanced", () => {
`shard weight spread ${maxTotal - minTotal}ms exceeds heaviest suite ${heaviest}ms: ${totals.join(", ")}`,
);
});
test("release server shards plus the dedicated chat file cover the original server group exactly", () => {
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"]);
const shards = Array.from({ length: 5 }, (_, index) => dryRunJson([
"--mode", "general", "--group", "general-server-without-chat",
"--shard-index", String(index), "--shard-count", "5",
]));
const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites);
const chat = "server/src/__tests__/chat-channels.integration.test.ts";
assert.ok(!files.includes(chat));
assert.deepEqual([...files, chat].sort(), full.selectedGeneralServerSuites.sort());
assert.equal(new Set(files).size, files.length);
const defaultRun = dryRunJson([]);
assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount);
});
const lineShardFile = path.join(repoRoot, "server/src/__tests__/chat-channels.integration.test.ts");
const caseAt = (line, name) => ({ name, file: lineShardFile, projectName: "@paperclipai/server", location: { line, column: 3 } });
test("test-line shards cover nested and parameterized cases exactly once without splitting a source line", () => {
const cases = [caseAt(10, "suite > nested > first"), caseAt(10, "suite > nested > second"),
caseAt(20, "same name"), caseAt(30, "same name"), caseAt(40, "last"), caseAt(50, "new case")];
const shards = partitionTestLines(cases, 3, lineShardFile);
assert.deepEqual(shards.map((shard) => shard.tests.length), [2, 2, 2]);
assert.equal(shards.filter((shard) => shard.lines.includes(10)).length, 1);
assert.equal(shards.find((shard) => shard.lines.includes(10)).tests.length, 2);
assert.equal(shards.flatMap((shard) => shard.lines).length, 5);
assert.deepEqual(shards.flatMap((shard) => shard.tests).sort((a, b) => a.location.line - b.location.line), cases);
assert.deepEqual(partitionTestLines([...cases].reverse(), 3, lineShardFile).map((shard) => shard.lines), shards.map((shard) => shard.lines));
});
test("line-shard collection rejects empty, foreign, or unlocated tests and invalid shard counts", () => {
const good = caseAt(10, "valid");
for (const input of [[], null, [{ ...good, file: "/another.test.ts" }], [{ ...good, projectName: "wrong" }],
[{ ...good, location: undefined }], [{ ...good, location: { line: 0 } }], [{ ...good, name: "" }]]) {
assert.throws(() => partitionTestLines(input, 1, lineShardFile));
}
for (const count of [0, -1, 1.5, Infinity, 2]) assert.throws(() => partitionTestLines([good], count, lineShardFile));
});
test("filtered collection must match the exact assigned case identities, including duplicates", () => {
const expected = [caseAt(10, "same"), caseAt(10, "same"), caseAt(20, "nested > case")];
assertSelectedTests(expected, [...expected].reverse(), lineShardFile);
for (const actual of [expected.slice(1), [...expected, caseAt(30, "extra")],
[expected[0], expected[1], caseAt(20, "renamed")],
[expected[0], expected[1], caseAt(21, "nested > case")]]) {
assert.throws(() => assertSelectedTests(expected, actual, lineShardFile));
}
});

View File

@ -0,0 +1,217 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import authorize from "../../.github/scripts/authorize-storybook-deploy.cjs";
const ownerFile = ".github/** @cryppadotta @devinfoley @nickyleach @forgottendev\n";
function fixture(overrides = {}) {
const calls = [];
const context = {
repo: { owner: "paperclipai", repo: "paperclip" },
eventName: "workflow_dispatch",
ref: "refs/heads/codex/example",
actor: "cryppadotta",
...overrides.context,
};
const environment = {
can_admins_bypass: false,
protection_rules: [{
type: "required_reviewers",
reviewers: [{ type: "User", reviewer: { login: "cryppadotta" } }],
}],
...overrides.environment,
};
const github = { rest: { repos: {
get: async () => ({ data: { default_branch: "master" } }),
getContent: async (params) => {
calls.push(params);
if (overrides.apiError) throw new Error("GitHub unavailable");
return { data: { encoding: "base64", content: Buffer.from(overrides.codeowners ?? ownerFile).toString("base64") } };
},
getEnvironment: async () => ({ data: environment }),
} } };
return { github, context, calls };
}
// Tests are serial because the Actions rerunner is an environment variable.
process.env.GITHUB_TRIGGERING_ACTOR = "cryppadotta";
test("allows each current CODEOWNER on a feature branch; reads policy from master", async () => {
for (const actor of ["cryppadotta", "devinfoley", "nickyleach", "forgottendev"]) {
const f = fixture({ context: { actor } });
await authorize(f);
assert.equal(f.calls[0].ref, "master");
assert.equal(f.calls[0].path, ".github/CODEOWNERS");
}
});
test("rejects non-owner initiators", async () => {
await assert.rejects(authorize(fixture({ context: { actor: "contributor" } })), /Only default-branch CODEOWNERS/);
});
test("rejects non-owner and missing rerunners, including deployment-only reruns", async () => {
for (const actor of ["contributor", ""]) {
process.env.GITHUB_TRIGGERING_ACTOR = actor;
await assert.rejects(authorize(fixture()), /Only default-branch CODEOWNERS/);
}
process.env.GITHUB_TRIGGERING_ACTOR = "cryppadotta";
});
test("comments, teams, emails and partial account matches do not grant access", async () => {
for (const codeowners of [
"# @cryppadotta\n.github/** @other",
".github/** @other # @cryppadotta",
".github/** @paperclipai/cryppadotta",
".github/** cryppadotta@example.com",
".github/** @cryppadotta-extra",
"",
]) await assert.rejects(authorize(fixture({ codeowners })), /CODEOWNERS/);
});
test("case-insensitive GitHub login matching", async () => {
await authorize(fixture({ context: { actor: "CryppaDotta" } }));
});
test("rejects forks, PR events, automatic events and tags", async () => {
for (const context of [
{ repo: { owner: "outsider", repo: "paperclip" } },
{ eventName: "pull_request" }, { eventName: "push" },
{ eventName: "workflow_call" }, { ref: "refs/tags/release" },
]) await assert.rejects(authorize(fixture({ context })));
});
test("fails closed when GitHub cannot return authoritative CODEOWNERS", async () => {
await assert.rejects(authorize(fixture({ apiError: true })), /GitHub unavailable/);
});
test("requires CODEOWNER environment reviewers with administrator bypass disabled", async () => {
for (const environment of [
{ can_admins_bypass: true },
{ protection_rules: [] },
{ protection_rules: [{ type: "required_reviewers", reviewers: [] }] },
{ protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "User", reviewer: { login: "contributor" } }] }] },
{ protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "Team", reviewer: { login: "cryppadotta" } }] }] },
]) await assert.rejects(authorize(fixture({ environment })), /must require CODEOWNER reviewers/);
});
test("workflow keeps branch build read-only and reauthorizes the protected deploy", () => {
const workflow = readFileSync(new URL("../../.github/workflows/storybook-deploy.yml", import.meta.url), "utf8");
const [build, deploy] = workflow.split(" build:")[1].split(" deploy:");
assert.doesNotMatch(build, /pages: write|id-token: write|secrets\./);
assert.match(build, /permissions: \{\}/);
assert.doesNotMatch(build, /actions\/checkout|cache: pnpm/);
assert.match(build, /package-manager-cache: false/);
assert.match(build, /pnpm install --frozen-lockfile --ignore-scripts/);
assert.match(deploy, /name: storybook-deploy/);
assert.match(deploy, /authorize-storybook-deploy.cjs/);
assert.match(deploy, /name: \$\{\{ needs.build.outputs.artifact_name \}\}/);
assert.doesNotMatch(workflow.split("permissions:")[0], /push:|pull_request:/);
});
import { storybookDestination, branchIndex } from '../../.github/scripts/storybook-destination.cjs';
const input = { branch: 'feature/foo', sha: 'a'.repeat(40), runId: 123, runAttempt: 1,
bucket: 'storybook-test', baseUrl: 'https://example.cloudfront.net' };
test('different branches have distinct stable URLs, including names that sanitize alike', () => {
const branches = ['feature/foo', 'feature-foo', 'Feature/foo', 'master', 'feature_foo', 'a'.repeat(100), 'a'.repeat(101)];
const urls = branches.map(branch => storybookDestination({ ...input, branch }).url);
assert.equal(new Set(urls).size, branches.length);
assert.ok(urls.every(url => /^https:\/\/example.cloudfront.net\/storybook\/branches\/[a-z0-9-]+\/index.html$/.test(url)));
});
test('redeploying a branch preserves its entry URL and creates a new build URL', () => {
const a = storybookDestination(input);
const b = storybookDestination({ ...input, sha: 'b'.repeat(40), runId: 124 });
assert.equal(a.url, b.url);
assert.notEqual(a.buildUrl, b.buildUrl);
assert.notEqual(a.buildUrl, storybookDestination({ ...input, runAttempt: 2 }).buildUrl);
});
test('invalid source and destination inputs fail closed', () => {
for (const change of [{ branch: '' }, { branch: 'a\nb' }, { sha: 'master' }, { runId: '../x' },
{ runAttempt: 0 }, { bucket: '../bucket' }, { baseUrl: 'http://example.com' },
{ baseUrl: 'https://user:password@example.com' }, { baseUrl: 'https://example.com/path' },
{ baseUrl: 'https://example.com?x=y' }]) {
assert.throws(() => storybookDestination({ ...input, ...change }));
}
});
test('branch entry preserves Storybook query and fragment deep links', () => {
const html = branchIndex(storybookDestination(input).buildUrl);
assert.match(html, /target.search = location.search/);
assert.match(html, /target.hash = location.hash/);
assert.match(html, /location.replace/);
});
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const publisher = fileURLToPath(new URL('../../.github/scripts/publish-storybook.cjs', import.meta.url));
function publishFixture(options = {}) {
const dir = mkdtempSync(path.join(tmpdir(), 'storybook-publish-test-'));
mkdirSync(path.join(dir, 'storybook-static'));
mkdirSync(path.join(dir, 'bin'));
for (const name of ['index.html', 'iframe.html', 'index.json']) writeFileSync(path.join(dir, 'storybook-static', name), 'fixture');
if (options.symlink) symlinkSync('/etc/passwd', path.join(dir, 'storybook-static', 'unsafe'));
const stub = path.join(dir, 'bin', 'aws');
writeFileSync(stub, `#!${process.execPath}\nconst fs=require('node:fs');fs.appendFileSync(process.env.UPLOAD_LOG,JSON.stringify(process.argv.slice(2))+'\\n');if(process.env.FAIL_UPLOAD==='1')process.exit(1);\n`);
chmodSync(stub, 0o755);
const result = spawnSync(process.execPath, [publisher], { cwd: dir, encoding: 'utf8', env: {
...process.env, PATH: `${path.join(dir, 'bin')}:${process.env.PATH}`, RUNNER_TEMP: dir,
SOURCE_BRANCH: input.branch, SOURCE_SHA: input.sha, GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1',
STORYBOOK_S3_BUCKET: input.bucket, STORYBOOK_PUBLIC_BASE_URL: input.baseUrl,
GITHUB_OUTPUT: path.join(dir, 'output'), GITHUB_STEP_SUMMARY: path.join(dir, 'summary'),
UPLOAD_LOG: path.join(dir, 'uploads'), FAIL_UPLOAD: options.fail ? '1' : '0',
} });
let uploads = [];
try { uploads = readFileSync(path.join(dir, 'uploads'), 'utf8').trim().split('\n').map(JSON.parse); } catch {}
let report = '';
let summary = '';
if (result.status === 0) {
report = readFileSync(path.join(dir, 'storybook-deployment.md'), 'utf8');
summary = readFileSync(path.join(dir, 'summary'), 'utf8');
}
rmSync(dir, { recursive: true, force: true });
return { result, uploads, report, summary };
}
test('publisher uploads a complete build then updates only that branch entry', () => {
const { result, uploads } = publishFixture();
assert.equal(result.status, 0, result.stderr);
assert.equal(uploads.length, 2);
const d = storybookDestination(input);
assert.ok(uploads[0].includes(`s3://${input.bucket}/${d.buildPrefix}/`));
assert.ok(uploads[1].includes(`s3://${input.bucket}/${d.prefix}/index.html`));
assert.ok(uploads[0].includes('--no-follow-symlinks'));
assert.doesNotMatch(JSON.stringify(uploads), /--delete/);
});
test('a failed build upload never changes the stable branch entry', () => {
const { result, uploads } = publishFixture({ fail: true });
assert.notEqual(result.status, 0);
assert.equal(uploads.length, 1);
assert.ok(uploads[0].includes('--recursive'));
});
test('artifact symlinks fail before any upload', () => {
const { result, uploads } = publishFixture({ symlink: true });
assert.notEqual(result.status, 0);
assert.equal(uploads.length, 0);
});
test('successful publication produces a downloadable Markdown report matching the run summary', () => {
const { result, report, summary } = publishFixture();
assert.equal(result.status, 0, result.stderr);
const d = storybookDestination(input);
assert.ok(report.includes(`[Branch Storybook](${d.url})`));
assert.ok(report.includes(`[This build](${d.buildUrl})`));
assert.ok(report.includes(d.sha));
assert.equal(report, summary);
});
import { verifyStorybook } from '../../.github/scripts/verify-storybook.cjs';
test('public verification retries a stale stable branch entry until it points to the new build', async () => {
const d = storybookDestination(input);
let indexReads = 0;
await verifyStorybook({ branchUrl: d.url, buildUrl: d.buildUrl, sha: d.sha,
sleep: async () => {}, attempts: 2, fetch: async (url) => String(url).endsWith('deployment.json')
? new Response(JSON.stringify({ sha: d.sha }))
: new Response(branchIndex(++indexReads === 1 ? d.buildUrl.replace('123-1', '122-1') : d.buildUrl)) });
assert.equal(indexReads, 2);
});
test('public verification rejects a permanently stale branch URL or wrong source commit', async () => {
const d = storybookDestination(input);
for (const wrong of ['branch', 'sha']) {
await assert.rejects(verifyStorybook({ branchUrl: d.url, buildUrl: d.buildUrl, sha: d.sha,
attempts: 1, fetch: async (url) => String(url).endsWith('deployment.json')
? new Response(JSON.stringify({ sha: wrong === 'sha' ? 'b'.repeat(40) : d.sha }))
: new Response(branchIndex(wrong === 'branch' ? d.buildUrl.replace('123-1', '122-1') : d.buildUrl)) }),
wrong === 'branch' ? /does not point to this build/ : /wrong source commit/);
}
});

View File

@ -0,0 +1,46 @@
#!/usr/bin/env node
import { pathToFileURL } from "node:url";
import { imageExists, packageExists, versionFor } from "./preview-artifacts.mjs";
/** Read-only availability gate. Deployment still resolves and pins artifacts. */
export async function waitForCloudArtifacts(sha, {
fetchImpl = fetch,
now = () => performance.now(),
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
timeoutMs = 30 * 60_000,
intervalMs = 20_000,
log = console.log,
} = {}) {
const version = versionFor(sha);
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isFinite(intervalMs) || intervalMs <= 0) {
throw new Error("Cloud readiness requires positive finite timeout and poll interval.");
}
const deadline = now() + timeoutMs;
let previous;
let missing = ["image", "shared", "db"];
while (now() < deadline) {
// Recheck every artifact on the successful poll. Only an explicit 404
// means publication is pending; identity errors and upstream outages fail.
const results = await Promise.all([
imageExists(sha, fetchImpl),
packageExists("@paperclipai/shared", sha, fetchImpl),
packageExists("@paperclipai/db", sha, fetchImpl),
]);
missing = ["image", "shared", "db"].filter((_, index) => !results[index]);
if (missing.length === 0) {
log(`Cloud artifacts available for ${sha}: verified image and exact-source migrator ${version}.`);
return { version: 1, sha, packageVersion: version };
}
const state = missing.join(", ");
if (state !== previous) log(`Waiting for cloud artifacts for ${sha}: ${state}.`);
previous = state;
const remaining = deadline - now();
if (remaining > 0) await sleep(Math.min(intervalMs, remaining));
}
throw new Error(`Cloud artifacts timed out for ${sha}; missing: ${missing.join(", ")}.`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try { await waitForCloudArtifacts(process.argv[2]); }
catch (error) { console.error(error.message); process.exitCode = 1; }
}

View File

@ -1,7 +1,9 @@
{
"$comment": "Per-suite wall-clock durations (ms) for the general-server vitest lane, used by scripts/general-server-shard.mjs to balance suites across the PR shard matrix. Sampled from a real PR run of .github/workflows/pr.yml (actions run 32708351172, 2026-08-24) by diffing consecutive per-suite completion timestamps in the 'Run grouped general test suites' logs \u2014 that captures each suite's true serial cost (import + collect + tests), not just the vitest-reported test time. Suites missing here get the median weight, so the manifest only needs occasional refreshes.",
"$chatSample": "chat-channels.integration.test.ts: actions run 34405038082, job 102646337040, 2026-09-09. The first suite completed at 21:19:52.9026416Z after Vitest RUN at 21:09:07.5491992Z: 645354ms rounded up, including startup/import/collection; the 985 tests themselves took 629654ms. All 2972 tests in the shard passed, but the job exceeded its unchanged 20-minute bound during cleanup. Recording this missing heavy-suite weight lets the existing LPT partition reserve one of the existing five shards without changing test coverage, isolation, or deadlines.",
"$nativeRunnerSample": "native-codex-runner.integration.test.ts: actions run 34555686996, job 103127786254, 2026-09-11. Consecutive suite completions at 02:50:34.2830368Z and 02:55:07.9837588Z give 273701ms including import/collection (test body 270773ms). This previously unweighted suite made one four-way server shard take 14m38s; recording its cost lets the existing LPT partition balance it in both PR and release runs.",
"durations": {
"server/src/services/native-runtime/native-codex-runner.integration.test.ts": 273701,
"server/src/__tests__/access-service.test.ts": 4757,
"server/src/__tests__/access-validators.test.ts": 645,
"server/src/__tests__/activity-log-responsible-user.test.ts": 4407,

View File

@ -74,6 +74,14 @@ export async function packageExists(name, sha, fetchImpl = fetch) {
return true;
}
export async function planArtifacts(sha, { migrator = false, image = true, fetchImpl = fetch } = {}) {
versionFor(sha);
return {
image: image && !await imageExists(sha, fetchImpl),
packages: migrator && !(await packageExists("@paperclipai/shared", sha, fetchImpl) && await packageExists("@paperclipai/db", sha, fetchImpl)),
};
}
export async function imageExists(sha, fetchImpl = fetch) {
versionFor(sha);
const tokenRes = await fetchImpl("https://ghcr.io/token?service=ghcr.io&scope=repository:paperclipai/paperclip:pull", { signal: AbortSignal.timeout(30_000) });
@ -178,12 +186,13 @@ export async function publishPreview(dir, sha, { fetchImpl = fetch, exec = execF
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [command, ...args] = process.argv.slice(2);
try {
if (command === "plan") {
if (command === "plan" || command === "plan-migrator") {
const [sha, requestId, migrator] = args;
validateRequest(sha, requestId);
if (process.env.GITHUB_REF !== "refs/heads/master") throw new Error("Preview workflow definitions must run from master.");
const image = !await imageExists(sha);
const packages = migrator === "true" && !(await packageExists("@paperclipai/shared", sha) && await packageExists("@paperclipai/db", sha));
const { image, packages } = await planArtifacts(sha, {
image: command === "plan", migrator: command === "plan-migrator" || migrator === "true",
});
appendFileSync(process.env.GITHUB_OUTPUT, `image=${image}\npackages=${packages}\n`);
} else if (command === "pack") packPreview(...args);
else if (command === "publish") await publishPreview(...args);
@ -195,6 +204,6 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
if (process.env.PREVIEW_MIGRATOR === "true" && !(await packageExists("@paperclipai/shared", sha) && await packageExists("@paperclipai/db", sha))) throw new Error("Preview packages are still missing.");
mkdirSync("stack-deploy-result", { recursive: true });
writeFileSync("stack-deploy-result/result.json", JSON.stringify({ version: 1, stage: "build", requestId, sha, status: "ready" }) + "\n");
} else throw new Error("Expected plan, pack, publish, publish-image, or result.");
} else throw new Error("Expected plan, plan-migrator, pack, publish, publish-image, or result.");
} catch (error) { console.error(error.message); process.exitCode = 1; }
}

View File

@ -1,9 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { planArtifacts } from "./preview-artifacts.mjs";
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { gzipSync } from "node:zlib";
import { execFileSync, spawnSync } from "node:child_process";
import { previewManifest, assertMetadata, validateRequest, versionFor, tarManifest, packageExists, imageExists, publishPreview, publishImage } from "./preview-artifacts.mjs";
const sha = "a".repeat(40);
@ -23,6 +25,35 @@ test("preview request requires immutable SHA and correlation UUID", () => {
assert.throws(() => validateRequest(sha, "not-a-request"));
});
test("migrator-only planning never waits for GHCR and reuses complete exact-source packages", async () => {
for (const available of [[], ["@paperclipai/shared"], ["@paperclipai/shared", "@paperclipai/db"]]) {
const calls = [];
const result = await planArtifacts(sha, { image: false, migrator: true, fetchImpl: async (url) => {
assert.equal(new URL(url).hostname, "registry.npmjs.org");
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
calls.push(name);
return available.includes(name) ? json({ ...manifest(name), dist: { integrity: "test-integrity", tarball: "https://registry.npmjs.org/package.tgz" } }) : json({}, 404);
} });
assert.deepEqual(result, { image: false, packages: available.length !== 2 });
assert.ok(calls.includes("@paperclipai/shared"));
if (available.length) assert.ok(calls.includes("@paperclipai/db"));
}
});
test("migrator-only planning rejects registry outages and mismatched source identity", async () => {
for (const response of [json({}, 403), json({}, 503), json({ ...manifest("@paperclipai/shared"), gitHead: "b".repeat(40) })]) {
await assert.rejects(planArtifacts(sha, { image: false, migrator: true, fetchImpl: async () => response }));
}
});
test("ordinary preview planning still requests a missing image without publishing unsolicited packages", async () => {
const result = await planArtifacts(sha, { fetchImpl: async (url) => {
assert.equal(new URL(url).hostname, "ghcr.io");
return url.includes("/token?") ? json({ token: "test-pull-token" }) : json({}, 404);
} });
assert.deepEqual(result, { image: true, packages: false });
});
test("preview manifests carry exact source, isolated versions and shared dependency", () => {
const pkg = manifest("@paperclipai/db");
assert.equal(pkg.version, `0.0.0-preview.g${sha}`);
@ -85,6 +116,24 @@ test("preview workflow separates branch compilation from trusted publishing", ()
assert.match(workflow, /Stack deploy \{0\} build/);
});
test("merge dispatch uses the existing publisher outside full-release concurrency without claiming image readiness", () => {
const dispatcher = readFileSync(new URL("../.github/workflows/cloud-artifacts.yml", import.meta.url), "utf8");
const release = readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8");
assert.match(dispatcher, /branches: \[master\]/);
assert.match(dispatcher, /github.ref == 'refs\/heads\/master'/);
assert.match(dispatcher, /SOURCE_SHA: \$\{\{ github.sha \}\}/);
assert.match(dispatcher, /gh workflow run release.yml .*--ref master/);
assert.match(dispatcher, /--field channel=cloud-migrator/);
assert.doesNotMatch(dispatcher, /actions\/checkout|id-token: write|packages: write|secrets\./);
assert.match(release, /\(inputs.channel == 'preview' \|\| inputs.channel == 'cloud-migrator'\) && format\('\{0\}-\{1\}', inputs.channel, inputs.source_ref\)/);
const publisher = release.split(" publish_preview:")[1].split(" image_preview:")[0];
assert.match(publisher, /group: preview-package-publish-\$\{\{ inputs.source_ref \}\}/);
assert.match(publisher, /cancel-in-progress: false/);
assert.match(release, /PLAN_COMMAND: \$\{\{ inputs.channel == 'cloud-migrator' && 'plan-migrator' \|\| 'plan' \}\}/);
const result = release.split(" result_preview:")[1].split(" verify_canary:")[0];
assert.match(result, /always\(\) && inputs.channel == 'preview'/);
});
test("existing image reuse verifies the full revision behind the immutable tag", async () => {
const digest = "sha256:" + "b".repeat(64);
@ -126,3 +175,119 @@ test("commits sharing a short prefix use separate full-SHA image addresses", asy
await imageExists(other, fetchImpl);
assert.deepEqual(urls.filter((url) => url.includes("/manifests/")), [sha, other].map((commit) => `https://ghcr.io/v2/paperclipai/paperclip/manifests/sha-${commit}-cloud`));
});
test("cloud builds start per commit and preserve tag promotion dependencies", () => {
const docker = readFileSync(new URL("../.github/workflows/docker.yml", import.meta.url), "utf8");
const cloud = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const readiness = readFileSync(new URL("../.github/workflows/cloud-readiness.yml", import.meta.url), "utf8");
assert.match(readiness, /branches: \[master\]/);
assert.match(readiness, /uses: \.\/\.github\/workflows\/docker-cloud.yml/);
assert.doesNotMatch(cloud, /^ push:/m);
assert.match(cloud, /workflow_call:/);
assert.match(cloud, /group: docker-cloud-\$\{\{ github.sha \}\}/);
assert.match(cloud, /cancel-in-progress: false/);
assert.doesNotMatch(cloud, /uses: .*@v\d\b/);
assert.match(cloud, /cache-to: type=registry,ref=ghcr.io\/\$\{\{ github.repository \}\}:buildcache-cloud-\$\{\{ github.sha \}\},mode=max/);
const caller = docker.split(" build-and-push-cloud:")[1].split(" promote_canary_channel:")[0];
assert.match(caller, /if: github.event_name != 'push' \|\| github.ref != 'refs\/heads\/master'/);
assert.match(caller, /uses: .\/.github\/workflows\/docker-cloud.yml/);
assert.match(docker.split(" promote_canary_channel:")[1], /needs: \[merge-and-push, build-and-push-cloud\]/);
const reaping = cloud.indexOf(" - name: Verify cloud PID 1 reaps orphaned processes");
assert.ok(reaping > cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"));
assert.ok(reaping < cloud.indexOf(" - name: Publish verified full-SHA cloud tag"));
});
test("cloud builds bake the managed runtime identity and verify it before publication", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const build = workflow.split(" - name: Build and push (cloud)")[1].split(" - name:")[0];
assert.match(build, /build-args: \|\n\s+USER_UID=1001\n\s+USER_GID=1001\n/);
const verify = workflow.indexOf(" - name: Verify cloud runtime user");
assert.ok(verify > workflow.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"));
assert.ok(verify < workflow.indexOf(" - name: Publish verified full-SHA cloud tag"));
const step = workflow.slice(verify).split("\n - name:")[0];
assert.match(step, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/);
assert.doesNotMatch(step, /continue-on-error:|if:/);
assert.ok(step.indexOf('--entrypoint sh "$IMAGE"') < step.indexOf('-e USER_UID=1001 -e USER_GID=1001'));
for (const flag of ["u", "g"]) {
assert.ok(step.includes(`test "$(id -${flag} node)" = 1001`));
assert.ok(step.includes(`test "$(id -${flag})" = 1001`));
}
assert.ok(step.includes('test -w "$PAPERCLIP_HOME"'));
});
test("cloud cache imports are bounded, follow master ancestry, and retain the legacy fallback", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const step = workflow.split(" - name: Select cloud cache ancestry")[1].split(" - name: Setup pnpm")[0];
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
const dir = mkdtempSync(path.join(tmpdir(), "cloud-cache-test-"));
const output = path.join(dir, "output");
const env = { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.test", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.test" };
const git = (...args) => execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", ...args], { cwd: dir, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
try {
git("init", "--initial-branch=master");
const commits = [];
for (let i = 0; i < 12; i++) {
git("commit", "--allow-empty", "-m", `main ${i}`);
commits.unshift(git("rev-parse", "HEAD"));
}
git("checkout", "-b", "topic", "HEAD~1");
git("commit", "--allow-empty", "-m", "topic");
git("checkout", "master");
git("merge", "--no-ff", "topic", "-m", "merge topic");
commits.unshift(git("rev-parse", "HEAD"));
const result = spawnSync("bash", ["-c", script], { cwd: dir, encoding: "utf8", env: { ...env, CACHE_IMAGE: "ghcr.io/paperclipai/paperclip", GITHUB_OUTPUT: output } });
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(readFileSync(output, "utf8").trim().split("\n"), [
"sources<<CACHE_SOURCES",
...commits.slice(0, 10).map((commit) => `type=registry,ref=ghcr.io/paperclipai/paperclip:buildcache-cloud-${commit}`),
"type=registry,ref=ghcr.io/paperclipai/paperclip:buildcache-cloud",
"CACHE_SOURCES",
]);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("normal cloud builds publish the checked digest only when source and platform match", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const cloud = workflow.split(" build-and-push-cloud:")[1];
const verify = cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version");
const publish = cloud.indexOf(" - name: Publish verified full-SHA cloud tag");
assert.ok(verify >= 0 && publish > verify);
const verification = cloud.slice(verify, publish);
assert.match(verification, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/);
assert.doesNotMatch(verification, /continue-on-error:|if: always\(/);
const step = cloud.slice(publish).split(/\n(?: #| - name:)/)[0];
assert.doesNotMatch(step, /continue-on-error:|if:/);
assert.match(step, /FULL_SHA_TAG: ghcr.io\/\$\{\{ github.repository \}\}:sha-\$\{\{ github.sha \}\}-cloud/);
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
const dir = mkdtempSync(path.join(tmpdir(), "cloud-tag-test-"));
const image = `ghcr.io/paperclipai/paperclip@sha256:${"b".repeat(64)}`;
const tag = `ghcr.io/paperclipai/paperclip:sha-${sha}-cloud`;
try {
writeFileSync(path.join(dir, "docker"), `#!/bin/sh
case "$1 $2" in
'image inspect')
case "$5" in
*revision*) printf '%s\\n' "$TEST_REVISION" ;;
*) printf '%s\\n' "$TEST_PLATFORM" ;;
esac ;;
'buildx imagetools') printf '%s\\n' "$@" > "$TEST_CALLS" ;;
*) exit 99 ;;
esac
`, { mode: 0o755 });
for (const [revision, platform, succeeds] of [[sha, "linux/amd64", true], ["c".repeat(40), "linux/amd64", false], [sha, "linux/arm64", false]]) {
const calls = path.join(dir, "calls");
rmSync(calls, { force: true });
const result = spawnSync("bash", ["-c", script], { encoding: "utf8", env: {
...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`, GITHUB_SHA: sha,
IMAGE: image, FULL_SHA_TAG: tag, TEST_REVISION: revision, TEST_PLATFORM: platform, TEST_CALLS: calls,
} });
if (succeeds) {
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(readFileSync(calls, "utf8").trim().split("\n"), ["buildx", "imagetools", "create", "--prefer-index=false", "--tag", tag, image]);
} else {
assert.notEqual(result.status, 0);
assert.throws(() => readFileSync(calls), { code: "ENOENT" });
}
}
} finally { rmSync(dir, { recursive: true, force: true }); }
});

View File

@ -1,11 +1,13 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readdirSync, realpathSync, statSync } from "node:fs";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadShardDurations, selectGeneralServerShard } from "./general-server-shard.mjs";
import { assertSelectedTests, partitionTestLines } from "./test-line-shard.mjs";
const repoRoot = process.cwd();
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
const generalServerShardDurations = loadShardDurations(
@ -66,11 +68,15 @@ const serializedModeName = "serialized";
const generalModeName = "general";
const allModeName = "all";
const generalServerGroupName = "general-server";
const generalServerWithoutChatGroupName = "general-server-without-chat";
const generalChatGroupName = "general-chat";
const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts";
const generalWorkspacesAGroupName = "general-workspaces-a";
const generalWorkspacesBGroupName = "general-workspaces-b";
const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"];
const generalWorkspacesBProjects = nonServerProjects.filter((project) => !generalWorkspacesAProjects.includes(project));
const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, generalWorkspacesBGroupName];
const allowedGeneralGroupNames = [...generalGroupNames, generalServerWithoutChatGroupName, generalChatGroupName];
const serializedServerVitestArgs = [
"--no-file-parallelism",
"--maxWorkers=1",
@ -216,10 +222,10 @@ function parseCliOptions(argv) {
const shardAllowed =
mode === serializedModeName ||
(mode === generalModeName &&
(group === generalServerGroupName || group === generalWorkspacesAGroupName));
([generalServerGroupName, generalServerWithoutChatGroupName, generalChatGroupName, generalWorkspacesAGroupName].includes(group)));
if (!shardAllowed && shardIndex !== null) {
fail(
"--shard-index/--shard-count are only valid with --mode serialized, --mode general --group general-server, or --mode general --group general-workspaces-a.",
"--shard-index/--shard-count are only valid with serialized mode or a shardable general server/chat/workspaces-a group.",
);
}
@ -227,8 +233,8 @@ function parseCliOptions(argv) {
fail("--group is only valid with --mode general.");
}
if (group !== null && !generalGroupNames.includes(group)) {
fail(`Unknown group "${group}". Expected one of: ${generalGroupNames.join(", ")}.`);
if (group !== null && !allowedGeneralGroupNames.includes(group)) {
fail(`Unknown group "${group}". Expected one of: ${allowedGeneralGroupNames.join(", ")}.`);
}
if (shardIndex !== null) {
@ -271,7 +277,7 @@ function selectSerializedSuites(routeTests, shardIndex, shardCount) {
return shardFiles.map((file) => byRepoPath.get(file));
}
function runVitest(args, label) {
function runVitest(args, label, testShard = null) {
console.log(`\n[test:run] ${label}`);
invocationIndex += 1;
const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp";
@ -291,6 +297,25 @@ function runVitest(args, label) {
};
mkdirSync(env.PAPERCLIP_HOME, { recursive: true });
mkdirSync(env.TMPDIR, { recursive: true });
if (testShard) {
const collect = (filters, name) => {
const output = path.join(testRoot, `${name}.json`);
const result = spawnSync("pnpm", ["exec", "vitest", "list", ...sourceOnlyVitestArgs,
...filters, "--allowOnly=false", "--includeTaskLocation", `--json=${output}`], {
cwd: repoRoot, env, stdio: "inherit",
});
if (result.error || result.status !== 0) fail(`Vitest collection failed: ${result.error?.message ?? result.status}`);
return JSON.parse(readFileSync(output, "utf8"));
};
const collected = collect(args, "all");
const file = path.resolve(repoRoot, chatSuite);
const selected = partitionTestLines(collected, testShard.count, file)[testShard.index];
const filters = selected.lines.map((line) => `${chatSuite}:${line}`);
args = [...args.filter((arg) => arg !== chatSuite), ...filters];
assertSelectedTests(selected.tests, collect(args, "selected"), file);
console.log(`[test:run] chat shard ${testShard.index + 1}/${testShard.count}: ${selected.tests.length}/${collected.length} tests, ${selected.lines.length} source lines; exact filter coverage verified`);
args.push("--allowOnly=false");
}
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...sourceOnlyVitestArgs, ...args], {
cwd: repoRoot,
env,
@ -325,16 +350,23 @@ function runProjectGroup(projects, groupName, shardIndex = null, shardCount = nu
}
function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount = null) {
if (groupName === generalServerGroupName) {
if (groupName === generalChatGroupName) {
runVitest(["--project", "@paperclipai/server", ...serializedServerVitestArgs, chatSuite],
"chat integration test shard", { index: shardIndex ?? 0, count: shardCount ?? 1 });
return;
}
if (groupName === generalServerGroupName || groupName === generalServerWithoutChatGroupName) {
const withoutChat = groupName === generalServerWithoutChatGroupName;
const files = withoutChat ? generalServerTestFiles.filter((file) => file !== chatSuite) : generalServerTestFiles;
if (shardCount !== null && shardCount > 1) {
const shardFiles = selectGeneralServerShard(
generalServerTestFiles,
files,
shardIndex,
shardCount,
generalServerShardDurations,
);
console.log(
`\n[test:run] general-server shard ${shardIndex + 1}/${shardCount} running ${shardFiles.length} of ${generalServerTestFiles.length} suites`,
`\n[test:run] general-server shard ${shardIndex + 1}/${shardCount} running ${shardFiles.length} of ${files.length} suites`,
);
if (shardFiles.length === 0) {
return;
@ -353,6 +385,7 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount =
}
const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]);
if (withoutChat) excludeRouteArgs.push("--exclude", "src/__tests__/chat-channels.integration.test.ts");
runVitest(
[
"--project",
@ -436,16 +469,16 @@ if (options.dryRun) {
shardIndex: options.shardIndex,
shardCount: options.shardCount,
group: options.group,
availableGeneralGroups: generalGroupNames,
availableGeneralGroups: allowedGeneralGroupNames,
serializedSuiteCount: routeTests.length,
selectedSerializedSuites: serializedSuites.map((routeTest) => routeTest.repoPath),
generalServerSuiteCount: generalServerTestFiles.length,
selectedGeneralServerSuites:
options.mode === generalModeName &&
options.group === generalServerGroupName &&
[generalServerGroupName, generalServerWithoutChatGroupName].includes(options.group) &&
options.shardCount !== null
? selectGeneralServerShard(
generalServerTestFiles,
options.group === generalServerWithoutChatGroupName ? generalServerTestFiles.filter((file) => file !== chatSuite) : generalServerTestFiles,
options.shardIndex,
options.shardCount,
generalServerShardDurations,

View File

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import path from "node:path";
function validateTests(tests, file) {
assert.ok(Array.isArray(tests) && tests.length > 0, "Vitest must collect at least one test");
for (const test of tests) {
assert.equal(test.projectName, "@paperclipai/server", "unexpected test project");
assert.equal(path.resolve(test.file), path.resolve(file), "unexpected test file");
assert.ok(typeof test.name === "string" && test.name.length > 0, "missing test name");
assert.ok(Number.isSafeInteger(test.location?.line) && test.location.line > 0, "missing test source line");
}
}
// Keep all cases registered on one source line together, including it.each
// and loop-generated cases. Balance by collected case count, not line count.
export function partitionTestLines(tests, count, file) {
validateTests(tests, file);
assert.ok(Number.isSafeInteger(count) && count > 0, "invalid shard count");
const byLine = new Map();
for (const test of tests) {
const line = test.location.line;
if (!byLine.has(line)) byLine.set(line, []);
byLine.get(line).push(test);
}
assert.ok(byLine.size >= count, "each shard must contain a source line");
const groups = [...byLine].sort((a, b) => b[1].length - a[1].length || a[0] - b[0]);
const shards = Array.from({ length: count }, () => ({ lines: [], tests: [] }));
for (const [line, cases] of groups) {
const shard = shards.reduce((best, next) => next.tests.length < best.tests.length ? next : best);
shard.lines.push(line);
shard.tests.push(...cases);
}
for (const shard of shards) shard.lines.sort((a, b) => a - b);
return shards;
}
// Re-collect using the exact filters passed to the subsequent test run. A
// Vitest filtering change must fail here instead of silently dropping cases.
export function assertSelectedTests(expected, actual, file) {
validateTests(actual, file);
const identities = (tests) => tests.map((test) => JSON.stringify([
test.projectName, path.resolve(test.file), test.location.line, test.name,
])).sort();
assert.deepEqual(identities(actual), identities(expected), "Vitest filters must select exactly the assigned tests");
}

View File

@ -37,6 +37,7 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
}));
const mockRunSecretRedactionRegistry = vi.hoisted(() => ({
redactForRuns: vi.fn(async (_companyId: string, values: unknown[]) => values),
redactForRun: vi.fn(
async (_companyId: string, _runId: string, value: unknown) => value,
),
@ -615,6 +616,8 @@ describe("agent live run routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(limit).toHaveBeenCalledWith(50);
expect(res.body).toHaveLength(50);
expect(mockRunSecretRedactionRegistry.redactForRuns).toHaveBeenCalledTimes(1);
expect(mockRunSecretRedactionRegistry.redactForRun).not.toHaveBeenCalled();
expect(mockHeartbeatService.buildRunOutputSilence).toHaveBeenCalledTimes(
50,
);
@ -659,6 +662,8 @@ describe("agent live run routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(limit).toHaveBeenCalledWith(50);
expect(res.body).toHaveLength(50);
expect(mockRunSecretRedactionRegistry.redactForRuns).toHaveBeenCalledTimes(1);
expect(mockRunSecretRedactionRegistry.redactForRun).not.toHaveBeenCalled();
});
it("does not pad with recent runs when no minCount is requested", async () => {

View File

@ -19,6 +19,7 @@ import {
secretAccessEvents,
} from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET, type AgentApiKeyScope } from "@paperclipai/shared";
import { REDACTED_EVENT_VALUE } from "../redaction.js";
import { errorHandler } from "../middleware/error-handler.js";
import { secretRoutes } from "../routes/secrets.js";
import { secretService } from "../services/secrets.js";
@ -248,6 +249,24 @@ describeEmbeddedPostgres("agent secret routes", () => {
expect((run.contextSnapshot as { paperclipSecretRedactions: unknown[] }).paperclipSecretRedactions).toHaveLength(1);
});
it("redacts batched runs from projected registries and enforces company scope", async () => {
const first = await seedAgentRun();
const foreign = await seedAgentRun();
const registry = createRunSecretRedactionRegistry(db);
await registry.register(first.companyId, first.heartbeatRunId, "first-secret-value");
await registry.register(foreign.companyId, foreign.heartbeatRunId, "foreign-secret-value");
const runs = [
{ id: first.heartbeatRunId, text: "first-secret-value foreign-secret-value", createdAt: new Date() },
{ id: foreign.heartbeatRunId, text: "foreign-secret-value", createdAt: new Date() },
];
const redacted = await registry.redactForRuns(first.companyId, runs);
expect(redacted[0].text).toBe(`${REDACTED_EVENT_VALUE} foreign-secret-value`);
expect(redacted[0].createdAt).toEqual(runs[0].createdAt);
expect(redacted[1].text).toBe("foreign-secret-value");
expect(await registry.redactForRun(first.companyId, first.heartbeatRunId, runs[0].text))
.toBe(redacted[0].text);
});
it("denies low-trust, task-bridge, and skill-test callers on both routes", async () => {
const lowTrust = await seedAgentRun({
trustPreset: LOW_TRUST_REVIEW_PRESET,

View File

@ -32,7 +32,7 @@ import {
or,
sql,
} from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
agentWakeupRequests,
@ -1022,8 +1022,30 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
rmSync(secretsTmpDir, { recursive: true, force: true });
});
// Services scan this file's shared database. Retire each case's fixtures
// after its assertions so another case (or shard order) cannot claim them.
const fixtureCompanies = new Set<string>();
const fixtureServices = new Set<ChatChannelService>();
afterEach(async () => {
try {
await Promise.all([...fixtureServices].map((service) => service.shutdown()));
} finally {
if (fixtureCompanies.size > 0) {
await db.update(chatEndpoints).set({ status: "paused" })
.where(and(inArray(chatEndpoints.companyId, [...fixtureCompanies]), eq(chatEndpoints.status, "active")));
// The milestone scanner also considers paused endpoints while their
// conversations are active. Retire those bindings after assertions.
await db.update(chatConversations).set({ state: "completed" })
.where(and(inArray(chatConversations.companyId, [...fixtureCompanies]), inArray(chatConversations.state, ["active", "waiting"])));
}
fixtureServices.clear();
fixtureCompanies.clear();
}
});
async function seedCompany() {
const companyId = randomUUID();
fixtureCompanies.add(companyId);
const assignedAgentId = randomUUID();
const replacementAgentId = randomUUID();
await db.insert(companies).values({
@ -1193,6 +1215,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
runtime: runtime as unknown as ChatSdkRuntime,
...serviceOverrides,
});
fixtureServices.add(service);
return { cancelRun, runtime, service, wakeup };
}
@ -20225,7 +20248,21 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
"@maya prove durable receipt",
);
expect(JSON.stringify(timingEvents)).not.toContain(signature);
const acceptedRedelivery = await observedRequest(true);
// A duplicate redelivery can momentarily contend with the first
// delivery's settlement and draw the retryable 503 — that is the
// webhook contract (Slack re-sends, the dedup path keeps it
// idempotent), not a defect. Retry the way the provider would
// instead of asserting an accidental no-contention property; this
// exact assertion drew a 503 under CI shard load on 2026-09-10.
let acceptedRedelivery = await observedRequest(true);
for (
let attempt = 0;
acceptedRedelivery.status === 503 && attempt < 20;
attempt += 1
) {
await new Promise((resolve) => setTimeout(resolve, 250));
acceptedRedelivery = await observedRequest(true);
}
expect(acceptedRedelivery.status).toBe(200);
await vi.waitFor(async () => {
const [delivery] = await db
@ -48459,21 +48496,28 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
.from(issues)
.where(eq(issues.companyId, fixture.companyId)),
).toHaveLength(0);
expect(
await db
.select()
.from(chatActions)
.where(
and(
eq(chatActions.endpointId, endpoint.id),
eq(chatActions.kind, "provider_effect"),
// The ephemeral post is observable before its provider_effect row
// settles, so under suite load the third row can still be
// mid-settlement when the mock resolves (drew a not-yet-processed row
// in CI on 2026-09-10). Wait for the bookkeeping, bounded, like the
// durable-receipt paths above do.
await vi.waitFor(async () => {
expect(
await db
.select()
.from(chatActions)
.where(
and(
eq(chatActions.endpointId, endpoint.id),
eq(chatActions.kind, "provider_effect"),
),
),
),
).toEqual([
expect.objectContaining({ kind: "provider_effect", status: "processed" }),
expect.objectContaining({ kind: "provider_effect", status: "processed" }),
expect.objectContaining({ kind: "provider_effect", status: "processed" }),
]);
).toEqual([
expect.objectContaining({ kind: "provider_effect", status: "processed" }),
expect.objectContaining({ kind: "provider_effect", status: "processed" }),
expect.objectContaining({ kind: "provider_effect", status: "processed" }),
]);
});
});
it("keeps Telegram start and unknown commands as terse guidance without creating work", async () => {

View File

@ -20,6 +20,7 @@ import { BUNDLED_PLUGIN_CATALOG } from "../services/bundled-plugins.js";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8");
const cloudWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8");
function parseList(source: string, pattern: RegExp, label: string): string[] {
const match = source.match(pattern);
@ -35,7 +36,7 @@ const dockerfileDefault = parseList(
"Dockerfile",
);
const workflowArg = parseList(
workflow,
cloudWorkflow,
/^\s*CLOUD_BUNDLED_PLUGINS=(.*)$/m,
"docker workflow",
);
@ -77,16 +78,17 @@ describe("cloud image bundled plugins", () => {
});
it("publishes the cloud image in its own job with no needs coupling", () => {
// The cloud publish runs as its own top-level job so the stock/production
// publish can never gate, delay, or skip it. Both jobs share only the
// single top-level concurrency slot; there is deliberately no `needs:`
// between them, so a failure in one is never coupled to the other.
const jobsSection = workflow.slice(workflow.indexOf("\njobs:\n"));
const caller = workflow.split(" build-and-push-cloud:")[1]?.split(" promote_canary_channel:")[0];
expect(caller, "tag and manual builds must call the cloud workflow").toContain("uses: ./.github/workflows/docker-cloud.yml");
expect(caller, "the reusable caller must also remain independent of production").not.toMatch(/^\s*needs:/m);
// The reusable cloud workflow owns its job and SHA concurrency group.
// Production publication must not gate, delay, or skip the cloud build.
const jobsSection = cloudWorkflow.slice(cloudWorkflow.indexOf("\njobs:\n"));
const headers = [...jobsSection.matchAll(/^ {2}([\w-]+):[^\n]*$/gm)];
expect(
headers.length,
"docker.yml must declare at least two jobs under jobs:",
).toBeGreaterThanOrEqual(2);
"docker-cloud.yml must declare a cloud build job under jobs:",
).toBeGreaterThanOrEqual(1);
// Locate the job block that carries the cloud build (target: cloud) and
// assert it declares no `needs:` — coupling it to another job would

View File

@ -37,7 +37,7 @@ import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8");
const serverPackageJson = JSON.parse(
readFileSync(path.join(repoRoot, "server", "package.json"), "utf8"),
) as { peerDependencies?: Record<string, string> };

View File

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { injectCloudUiSnippet } from "../cloud-ui-snippet.js";
const html = '<html><body><div id="root"></div></body></html>';
const snippet = '<script src="https://example.com/widget.js"></script>';
describe("Cloud UI snippet", () => {
it("leaves self-hosted HTML unchanged even when a snippet is configured", () => {
expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET: snippet })).toBe(html);
});
it.each([
{ PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "test-token" },
{ PAPERCLIP_MANAGED_CONFIG: "{}" },
])("injects only on a configured Cloud instance: %j", (cloud) => {
expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: snippet }))
.toBe(html.replace("</body>", `${snippet}\n</body>`));
expect(injectCloudUiSnippet(html, cloud)).toBe(html);
expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: " " })).toBe(html);
});
it("preserves literal replacement tokens in operator JavaScript", () => {
const script = '<script>console.log("$&", "$`", "$\'");</script>';
const result = injectCloudUiSnippet(html, {
PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: script,
});
expect(result).toContain(script);
expect(result).not.toContain("test-token");
});
});

View File

@ -21,6 +21,7 @@ import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8");
const cloudWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8");
/**
* Return the text of the Dockerfile stage that starts at the named target.
@ -35,6 +36,25 @@ function stageBody(source: string, stageName: string): string {
return source.slice(start, end);
}
it("keeps per-build runtime metadata out of the weekly CLI-install cache", () => {
const production = stageBody(dockerfile, "production");
const tools = production.search(/^RUN echo "cli-tools-epoch:/m);
const entrypoint = production.search(/^RUN chmod \+x \/usr\/local\/bin\/docker-entrypoint\.sh/m);
const runtime = production.search(/^ENV NODE_ENV=production/m);
const epoch = production.search(/^ARG CLI_TOOLS_CACHE_EPOCH\b/m);
expect(tools).toBeGreaterThanOrEqual(0);
expect(entrypoint).toBeGreaterThan(tools);
expect(epoch).toBeGreaterThanOrEqual(0);
expect(epoch).toBeLessThan(tools);
for (const name of ["PAPERCLIP_BUILD_VERSION", "PAPERCLIP_BUILD_COMMIT"]) {
const declarations = [...production.matchAll(new RegExp(`^ARG ${name}\\b`, "gm"))];
expect(declarations).toHaveLength(1);
expect(declarations[0].index).toBeGreaterThan(entrypoint);
expect(declarations[0].index).toBeLessThan(runtime);
expect(production.slice(runtime)).toContain(`${name}=\${${name}}`);
}
});
describe("docker build-stamp wiring", () => {
it("declares PAPERCLIP_BUILD_COMMIT in the build stage before the server build", () => {
const build = stageBody(dockerfile, "build");
@ -49,7 +69,7 @@ describe("docker build-stamp wiring", () => {
});
it("passes PAPERCLIP_BUILD_COMMIT as a build-arg for both image targets", () => {
const argLines = [...workflow.matchAll(/^\s*PAPERCLIP_BUILD_COMMIT=.*$/gm)];
const argLines = [...`${workflow}\n${cloudWorkflow}`.matchAll(/^\s*PAPERCLIP_BUILD_COMMIT=.*$/gm)];
expect(
argLines.length,
"the docker workflow must pass PAPERCLIP_BUILD_COMMIT for the production and cloud builds",

View File

@ -98,6 +98,18 @@ describe("docker-entrypoint.sh", () => {
expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN");
});
it.each([false, true])("skips remapping a cloud identity while preserving volume repair (mismatch: %s)", async (homeMismatch) => {
installStubs({ uid: 0, gid: 0, nodeUid: 1001, nodeGid: 1001, homeMismatch });
const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir });
expect(stdout).toContain("ENTRYPOINT-CMD-RAN");
expect(calls).not.toContain("usermod");
expect(calls).not.toContain("groupmod");
expect(calls.includes(`chown -R node:node ${stubDir}`)).toBe(homeMismatch);
expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN");
});
it("chowns a root-owned home before gosu even with the default UID/GID (fresh volume mount)", async () => {
// A freshly mounted volume arrives root-owned and shadows the image's
// build-time chown; with no remap requested the old entrypoint dropped

View File

@ -1389,6 +1389,90 @@ describeEmbeddedPostgres("environmentService leases", () => {
expect(activity.at(-1)?.action).toBe("environment.managed_stock_skipped");
});
it("platformFullyManaged applies drift instead of preserving it, since no operator can edit this row", async () => {
const companyId = await seedCompany();
const created = await svc.ensureManagedSandboxEnvironment({
companyId,
name: "Daytona",
description: "Managed stock",
provider: "daytona",
config: { target: "us" },
stockVersion: "v1",
platformFullyManaged: true,
});
// Same drift shape as the plain "classifies operator drift" case above:
// from the reconciler's point of view, a row whose content matches
// neither the recorded binding hash nor the latest stock hash is
// indistinguishable between "an operator edited it" and "two
// platform-driven reconciliation passes disagreed" (e.g. a stock hash
// recorded by an older app build). `platformFullyManaged` asserts the
// caller's deployment rules out the former, so this must apply like any
// other stock-outdated row rather than freeze the row and only bump the
// binding's bookkeeping.
await db
.update(environments)
.set({
config: { provider: "daytona", target: "drifted" },
})
.where(eq(environments.id, created.environment.id));
const reconciled = await svc.ensureManagedSandboxEnvironment({
companyId,
name: "Daytona v2",
description: "Managed stock v2",
provider: "daytona",
config: { target: "eu" },
stockVersion: "v2",
platformFullyManaged: true,
});
expect(reconciled).toMatchObject({
action: "updated",
stockStatus: "stock_update_available",
updateAvailable: false,
});
expect(reconciled.environment).toMatchObject({
name: "Daytona v2",
description: "Managed stock v2",
config: { provider: "daytona", target: "eu" },
});
const [bindingAfter] = await db
.select()
.from(builtInManagedResources)
.where(eq(builtInManagedResources.companyId, companyId));
expect(bindingAfter?.stockVersion).toBe("v2");
expect(bindingAfter?.stockHash).toBe(reconciled.stockHash);
});
it("platformFullyManaged still preserves an operator-reaffirmed archive decision", async () => {
const companyId = await seedCompany();
const created = await svc.ensureManagedSandboxEnvironment({
companyId,
name: "Daytona",
provider: "daytona",
config: { target: "us" },
platformFullyManaged: true,
});
expect((await svc.archiveManagedSandboxEnvironment({ provider: "daytona" }))?.status)
.toBe("archived");
expect((await svc.update(created.environment.id, { status: "archived" }))?.status)
.toBe("archived");
const reconciled = await svc.ensureManagedSandboxEnvironment({
companyId,
name: "Daytona",
provider: "daytona",
config: { target: "us" },
platformFullyManaged: true,
});
expect(reconciled).toMatchObject({
action: "skipped",
stockStatus: "operator_modified",
updateAvailable: true,
environment: { status: "archived" },
});
});
it("adopts the managed slot on a provider switch and drops the stale kubernetes marker", async () => {
const companyId = await seedCompany();
const kubernetes = await svc.ensureKubernetesEnvironment(companyId, { inCluster: true, backend: "job" });
@ -1579,6 +1663,44 @@ describeEmbeddedPostgres("environmentService leases", () => {
expect(rows).toHaveLength(1);
});
it("platformFullyManaged never adopts an unbound same-name sandbox row", async () => {
// Same setup as the plain case above: a tenant-created sandbox row holds
// the desired name and has no stock binding. It reads as
// `operator_modified` too, but there is no prior platform pass for it to
// have drifted from — the bypass must require a binding for this exact
// row, or it would overwrite the tenant's config and stamp it managed.
const companyId = await seedCompany();
const handMade = await svc.create({
name: "Daytona",
driver: "sandbox",
status: "active",
config: { provider: "daytona", target: "us" },
});
expect(handMade.metadata?.managedByPaperclip).toBeUndefined();
const reconciliation = await svc.ensureManagedSandboxEnvironment({
companyId,
name: "Daytona",
provider: "daytona",
config: { target: "eu" },
platformFullyManaged: true,
});
expect(reconciliation).toMatchObject({
action: "skipped",
stockStatus: "operator_modified",
updateAvailable: true,
});
expect(reconciliation.environment.id).toBe(handMade.id);
expect(reconciliation.environment.config.target).toBe("us");
expect(reconciliation.environment.metadata?.managedByPaperclip).toBeUndefined();
const rows = await db
.select()
.from(environments)
.where(eq(environments.driver, "sandbox"));
expect(rows).toHaveLength(1);
});
it("keeps the current name when the desired name belongs to another row", async () => {
const companyId = await seedCompany();
await svc.create({

View File

@ -25,6 +25,7 @@ import {
heartbeatService,
} from "../services/heartbeat.ts";
import { runningProcesses } from "../adapters/index.ts";
import { recoveryService } from "../services/recovery/service.ts";
const mockAdapterExecute = vi.hoisted(() =>
vi.fn(async () => ({
@ -395,6 +396,93 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
]);
});
it.each([
{ runtimeMode: "native", status: "done", reassigned: false },
{ runtimeMode: "legacy", status: "done", reassigned: false },
{ runtimeMode: "native", status: "cancelled", reassigned: false },
{ runtimeMode: "native", status: "backlog", reassigned: false },
{ runtimeMode: "native", status: "in_review", reassigned: false },
{ runtimeMode: "native", status: "blocked", reassigned: false },
{ runtimeMode: "native", status: "in_progress", reassigned: true },
] as const)("skips stale $runtimeMode productive recovery after status=$status reassigned=$reassigned commits under the enqueue lock", async ({ runtimeMode, status, reassigned }) => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
const runId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Completion racing with productive recovery",
status: "in_progress",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
runtimeMode,
status: "succeeded",
livenessState: "completed",
contextSnapshot: { issueId, wakeReason: "issue_assigned" },
startedAt: new Date(),
finishedAt: new Date(),
});
// Let the real sweep select its in-progress snapshot, then hold the issue
// lock until the real enqueue transaction is waiting on the newer state.
const enqueueWakeup = vi.fn(async (...[targetAgentId, options]: Parameters<typeof heartbeat.wakeup>) => {
let pendingWake!: ReturnType<typeof heartbeat.wakeup>;
await db.transaction(async (tx) => {
await tx.update(issues).set({
status,
...(reassigned ? { assigneeAgentId: null, assigneeUserId: "responsible-user" } : {}),
}).where(eq(issues.id, issueId));
const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
pendingWake = heartbeat.wakeup(targetAgentId, options);
try {
expect(await waitForCondition(async () => {
const [{ waiting }] = await db.execute<{ waiting: boolean }>(sql`
select exists (
select 1 from pg_stat_activity
where ${pid} = any(pg_blocking_pids(pid))
) as waiting
`);
return waiting;
})).toBe(true);
} catch (error) {
// Observe a pending rejection even if the lock assertion fails.
void pendingWake.catch(() => {});
throw error;
}
});
return pendingWake;
});
const recovery = recoveryService(db, { enqueueWakeup });
const result = await recovery.reconcileStrandedAssignedIssues();
expect(enqueueWakeup).toHaveBeenCalledOnce();
expect(result).toMatchObject({ continuationRequeued: 0, escalated: 0, skipped: 1, issueIds: [] });
expect(await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)).toEqual([{ id: runId }]);
expect(await db.select().from(issueComments)).toHaveLength(0);
expect(mockAdapterExecute).not.toHaveBeenCalled();
const [wakeup] = await db.select().from(agentWakeupRequests);
expect(wakeup).toMatchObject({
status: "skipped",
reason: "issue_state_guard_mismatch",
runId: null,
payload: {
heartbeatSkip: {
expectedStatuses: ["in_progress"],
actualStatus: status,
expectedAssigneeAgentId: agentId,
actualAssigneeAgentId: reassigned ? null : agentId,
},
},
});
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(issue).toMatchObject({ status, assigneeAgentId: reassigned ? null : agentId });
});
it("cancels a resolved connection-intent wake parked before queued-run claim", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();

View File

@ -46,7 +46,25 @@ export async function startRunnerApiTestServer() {
if (options.connectionScenario !== undefined && !CONNECTION_SCENARIOS.includes(options.connectionScenario)) throw new Error(`Unknown connection eval scenario: ${String(options.connectionScenario)}`);
// This DB is created inside this helper, never supplied by a caller. Paid
// paired runs reset it between attempts so modeled IDs and data match.
if (options.reset) await db.execute(sql`TRUNCATE companies CASCADE`);
// The helper's own app runs background sweeps against this DB, and one
// can hold row locks when the reset fires; Postgres then picks a
// deadlock victim (observed against TRUNCATE in CI on 2026-09-10). The
// loser's transaction rolls back the moment it is chosen, so a short
// bounded retry makes the reset deterministic instead of flaky.
if (options.reset) {
for (let attempt = 0; ; attempt += 1) {
try {
await db.execute(sql`TRUNCATE companies CASCADE`);
break;
} catch (error) {
const code =
(error as { code?: string }).code ??
(error as { cause?: { code?: string } }).cause?.code;
if (attempt >= 4 || code !== "40P01") throw error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
const id = (key: string) => {
if (!options.reset) return randomUUID();
const hex = createHash("sha256").update(`runner-api-fixture:${key}`).digest("hex");

View File

@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { REDACTED_EVENT_VALUE } from "../redaction.js";
import { redactRegisteredSecretValues } from "../services/run-secret-redaction.js";
import type { Db } from "@paperclipai/db";
import { PgDialect } from "drizzle-orm/pg-core";
import { createRunSecretRedactionRegistry, redactRegisteredSecretValues } from "../services/run-secret-redaction.js";
const secret = "q2a-exact-secret-value";
@ -76,3 +78,50 @@ describe("registered run secret redaction", () => {
expect(result.createdAt.toISOString()).toBe("2026-08-06T12:00:00.000Z");
});
});
const { resolveVersion } = vi.hoisted(() => ({ resolveVersion: vi.fn(async ({ material }) => material.value as string) }));
vi.mock("../secrets/provider-registry.js", () => ({ getSecretProvider: () => ({ resolveVersion }) }));
describe("batched run secret redaction", () => {
beforeEach(() => { resolveVersion.mockClear(); });
function fixture(rows: unknown[]) {
const where = vi.fn(async (_predicate: import("drizzle-orm").SQL | undefined) => rows);
const select = vi.fn((_columns: { contextSnapshot: import("drizzle-orm").SQL }) => ({ from: () => ({ where }) }));
return { registry: createRunSecretRedactionRegistry({ select } as unknown as Db), select, where };
}
it("reads only registry JSON once for 200 runs and resolves shared secrets once", async () => {
const contextSnapshot = { paperclipSecretRedactions: [{ fingerprintSha256: "shared", material: { value: secret } }] };
const rows = Array.from({ length: 200 }, (_, i) => ({ id: `run-${i}`, contextSnapshot }));
const { registry, select, where } = fixture(rows);
const result = await registry.redactForRuns("company-1", rows.map(row => ({ ...row, stdoutExcerpt: secret })));
expect(select).toHaveBeenCalledTimes(1);
expect(resolveVersion).toHaveBeenCalledTimes(1);
expect(result.every(run => run.stdoutExcerpt === REDACTED_EVENT_VALUE)).toBe(true);
expect(result[0].contextSnapshot).toEqual({});
const dialect = new PgDialect();
const predicate = dialect.sqlToQuery(where.mock.calls[0][0]);
expect(predicate.params).toContain("company-1");
expect(predicate.sql).toContain('"company_id"');
expect(dialect.sqlToQuery(select.mock.calls[0][0].contextSnapshot).sql).toContain("-> 'paperclipSecretRedactions'");
});
it("keeps each run's registry separate and observes new registrations on the next request", async () => {
const rows = [{ id: "a", contextSnapshot: { paperclipSecretRedactions: [{ fingerprintSha256: "one", material: { value: secret } }] } }];
const { registry } = fixture(rows);
expect(await registry.redactForRuns("company", [{ id: "a", text: secret }, { id: "b", text: secret }]))
.toEqual([{ id: "a", text: REDACTED_EVENT_VALUE }, { id: "b", text: secret }]);
rows[0].contextSnapshot.paperclipSecretRedactions.push({ fingerprintSha256: "two", material: { value: "new-secret" } });
expect(await registry.redactForRuns("company", [{ id: "a", text: "new-secret" }]))
.toEqual([{ id: "a", text: REDACTED_EVENT_VALUE }]);
});
it("does not query for an empty list and fails closed on decryption failure", async () => {
const { registry, select } = fixture([{ id: "a", contextSnapshot: { paperclipSecretRedactions: [{ fingerprintSha256: "one", material: {} }] } }]);
expect(await registry.redactForRuns("company", [])).toEqual([]);
expect(select).not.toHaveBeenCalled();
resolveVersion.mockRejectedValueOnce(new Error("unavailable"));
await expect(registry.redactForRuns("company", [{ id: "a", text: secret }])).rejects.toThrow("unavailable");
});
});

View File

@ -3,18 +3,31 @@ import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { readBrandedStaticIndexHtml } from "../static-index-html.js";
describe("static SPA fallback HTML", () => {
const tempDirs: string[] = [];
afterEach(() => {
vi.unstubAllEnvs();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("includes the operator snippet only in Cloud-served static HTML", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-html-"));
tempDirs.push(dir);
fs.writeFileSync(path.join(dir, "index.html"), "<html><body>App</body></html>");
vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", '<script src="https://example.com/chat.js"></script>');
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined);
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
expect(readBrandedStaticIndexHtml(dir)).not.toContain("chat.js");
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}");
expect(readBrandedStaticIndexHtml(dir)).toContain('chat.js"></script>\n</body>');
});
it("serves the current index.html instead of reusing stale asset hashes", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-static-index-"));
tempDirs.push(tempDir);

View File

@ -17094,6 +17094,28 @@ describeEmbeddedPostgres("tool access service", () => {
lastHealthAt: new Date(0),
})
.returning();
const [pluginApplication] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `paperclip_plugin:fixture-${randomUUID()}`,
name: "Plugin placeholder",
type: "paperclip_plugin",
status: "active",
metadata: { source: "plugin_backfill" },
}).returning();
const [pluginConnection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: pluginApplication!.id,
name: "Plugin placeholder",
uid: `plugin-${randomUUID()}`,
connectionKind: "managed",
transport: "mcp_remote",
status: "active",
enabled: true,
config: { type: "paperclip_plugin" },
transportConfig: { type: "paperclip_plugin" },
healthStatus: "ok",
healthCheckedAt: null,
}).returning();
const connection = await service.createConnection(company.id, {
name: "Swept remote",
transport: "mcp_remote",
@ -17102,7 +17124,7 @@ describeEmbeddedPostgres("tool access service", () => {
status: "active",
});
const sweep = await service.sweepConnectionHealth({ staleAfterMs: 0 });
const sweep = await service.sweepConnectionHealth({ staleAfterMs: 0, limit: 1 });
const [updatedConnection] = await db
.select()
.from(toolConnections)
@ -17112,6 +17134,10 @@ describeEmbeddedPostgres("tool access service", () => {
.from(toolConnections)
.where(eq(toolConnections.id, chatConnection!.id));
const [untouchedPlugin] = await db.select().from(toolConnections)
.where(eq(toolConnections.id, pluginConnection!.id));
expect(untouchedPlugin).toMatchObject({ enabled: true, healthStatus: "ok", healthCheckedAt: null });
expect(sweep).toMatchObject({
checked: 1,
healthy: 0,

View File

@ -0,0 +1,60 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
it("runs every active nested/parameterized fixture case exactly once through the real chat shard CLI", () => {
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "pc-shards-")));
try {
const tests = path.join(root, "server/src/__tests__");
mkdirSync(tests, { recursive: true });
symlinkSync(path.join(repoRoot, "node_modules"), path.join(root, "node_modules"), "junction");
writeFileSync(path.join(root, "package.json"), JSON.stringify({ private: true }));
writeFileSync(path.join(root, "vitest.config.mjs"), `export default {
test: { projects: [{ test: { name: "@paperclipai/server", root: ${JSON.stringify(path.join(root, "server"))},
include: ["src/**/*.test.ts"], pool: "forks", maxWorkers: 1 } }] }
};`);
const trace = path.join(root, "executed.jsonl");
const fixture = path.join(tests, "chat-channels.integration.test.ts");
writeFileSync(fixture, `import { appendFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
let active = false;
beforeEach(() => { expect(active).toBe(false); active = true; });
afterEach(() => { active = false; });
function record(id) { expect(active).toBe(true); appendFileSync(${JSON.stringify(trace)}, JSON.stringify(id) + "\\n"); }
it("top-level", () => record("top"));
describe("nested", () => {
it("first", () => record("nested-first"));
it("second", () => record("nested-second"));
it.each(["a", "b", "c", "d"])("parameter %s", (value) => record(value));
it.skip("intentionally skipped", () => { throw new Error("must stay skipped"); });
});`);
const run = (index: number, count: number) => spawnSync(process.execPath, [
path.join(repoRoot, "scripts/run-vitest-stable.mjs"), "--mode", "general", "--group", "general-chat",
"--shard-index", String(index), "--shard-count", String(count),
], { cwd: root, env: { ...process.env, CI: "true" }, encoding: "utf8", timeout: 45_000, maxBuffer: 4 * 1024 * 1024 });
for (const index of [0, 1]) {
const result = run(index, 2);
expect(result.error, result.stderr).toBeUndefined();
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(result.stdout).toContain("exact filter coverage verified");
}
const executed = readFileSync(trace, "utf8").trim().split("\n").map((line) => JSON.parse(line));
expect(executed.sort()).toEqual(["a", "b", "c", "d", "nested-first", "nested-second", "top"]);
// A real assertion failure must still fail the wrapper after successful
// collection and filter validation.
writeFileSync(fixture, 'import { it } from "vitest"; it("fails", () => { throw new Error("fixture failure"); });');
const failed = run(0, 1);
expect(failed.error, failed.stderr).toBeUndefined();
expect(failed.stdout).toContain("exact filter coverage verified");
expect(failed.status).not.toBe(0);
expect(failed.stdout + failed.stderr).toContain("fixture failure");
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 120_000);

View File

@ -0,0 +1,44 @@
import type { ExecutionWorkspace } from "@paperclipai/shared";
import { afterEach, expect, it, vi } from "vitest";
import { createWorkspaceGitInspectionCache } from "../services/workspace-git-inspection-cache.js";
const workspace = { id: "workspace", companyId: "company", cwd: "/repo", baseRef: "master" } as ExecutionWorkspace;
afterEach(() => vi.useRealTimers());
it("coalesces concurrent display reads and expires after five seconds", async () => {
vi.useFakeTimers();
const inspect = vi.fn(async () => ({ dirty: false }));
const read = createWorkspaceGitInspectionCache(inspect);
await Promise.all(Array.from({ length: 100 }, () => read(workspace)));
expect(inspect).toHaveBeenCalledTimes(1);
await read(workspace);
expect(inspect).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(5_000);
await read(workspace);
expect(inspect).toHaveBeenCalledTimes(2);
// Callers that authorize cleanup retain the uncached inspector.
await inspect();
expect(inspect).toHaveBeenCalledTimes(3);
});
it("does not share results across companies, paths, base refs or workspace revisions", async () => {
const inspect = vi.fn(async () => null);
const read = createWorkspaceGitInspectionCache(inspect);
await read(workspace);
await read({ ...workspace, companyId: "other" });
await read({ ...workspace, cwd: "/other" });
await read({ ...workspace, baseRef: "other" });
await read({ ...workspace, updatedAt: new Date() });
expect(inspect).toHaveBeenCalledTimes(5);
});
it("retries failed inspections and bounds retained entries", async () => {
const inspect = vi.fn(async () => null).mockRejectedValueOnce(new Error("failed"));
const read = createWorkspaceGitInspectionCache(inspect);
await expect(read(workspace)).rejects.toThrow("failed");
await read(workspace);
expect(inspect).toHaveBeenCalledTimes(2);
for (let i = 0; i < 256; i++) await read({ ...workspace, id: String(i) });
await read(workspace);
expect(inspect).toHaveBeenCalledTimes(259);
});

View File

@ -114,6 +114,7 @@ import { adapterRoutes } from "./routes/adapters.js";
import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js";
import { remoteAgentProfileRoutes } from "./routes/remote-agent-profiles.js";
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
import { injectCloudUiSnippet } from "./cloud-ui-snippet.js";
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
import { staticUiCacheControl } from "./static-ui-cache.js";
import { applyUiBranding } from "./ui-branding.js";
@ -952,6 +953,10 @@ export async function createApp(
immutable: true,
}),
);
// Serve root/index through the same runtime HTML transform as SPA routes.
app.get(["/", "/index.html"], (_req, res) => {
res.type("html").set("Cache-Control", "no-cache").send(readBrandedStaticIndexHtml(uiDist));
});
// Non-hashed static files (favicon.ico, manifest, robots.txt, etc.):
// short cache so operators who swap them out see the new version
// reasonably fast, with must-revalidate overrides for index.html and
@ -1060,7 +1065,7 @@ export async function createApp(
viteHtmlRenderer = createCachedViteHtmlRenderer({
vite,
uiRoot,
brandHtml: applyUiBranding,
brandHtml: (html) => injectCloudUiSnippet(applyUiBranding(html)),
});
const renderViteHtml = viteHtmlRenderer;

View File

@ -0,0 +1,8 @@
import { isCloudManagedInstance, type CloudInstanceEnv } from "./services/cloud-instance.js";
/** Trusted operator HTML only. This content is public and runs in the app origin. */
export function injectCloudUiSnippet(html: string, env: CloudInstanceEnv = process.env): string {
const snippet = env.PAPERCLIP_CLOUD_UI_SNIPPET;
if (!isCloudManagedInstance(env) || !snippet?.trim()) return html;
return html.replace(/<\/body>/i, () => `${snippet}\n</body>`);
}

View File

@ -6222,7 +6222,7 @@ export function agentRoutes(
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined;
const summary = req.query.summary === "true" || req.query.summary === "1";
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
res.json(await Promise.all(runs.map((run) => runRedactions.redactForRun(companyId, run.id, run))));
res.json(await runRedactions.redactForRuns(companyId, runs));
});
router.get("/companies/:companyId/provider-traces", async (req, res) => {
@ -6330,24 +6330,24 @@ export function agentRoutes(
const rows = [...liveRuns, ...recentRuns];
const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id));
res.json(await Promise.all(rows.map(async (run) => runRedactions.redactForRun(companyId, run.id, {
res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({
...heartbeat.decorateActiveRunStatus(run),
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
execution: projections.get(run.id) ?? null,
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
})))));
return;
}
const projections = await executionProjectionsForRuns(db, companyId, liveRuns.map(run => run.id));
res.json(await Promise.all(liveRuns.map(async (run) => runRedactions.redactForRun(companyId, run.id, {
res.json(await runRedactions.redactForRuns(companyId, await Promise.all(liveRuns.map(async (run) => ({
...heartbeat.decorateActiveRunStatus(run),
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
execution: projections.get(run.id) ?? null,
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
})))));
});
router.get("/heartbeat-runs/:runId", async (req, res) => {

View File

@ -105,6 +105,18 @@ export interface ManagedSandboxEnvironmentInput {
extraMetadata?: Record<string, unknown>;
/** Version label recorded with the stock binding; hashes remain the drift authority. */
stockVersion?: string;
/**
* Asserts the caller's deployment gives no operator any path to hand-edit
* this row (currently only true for the PAPERCLIP_MANAGED_CONFIG applier,
* where `enableManagedSandboxOnly` removes the tenant's own environment
* choice entirely). When set, a plain content-hash mismatch against a real
* prior binding is treated as ordinary stock drift instead of an operator
* customization to protect see the `operator_modified` handling below.
* Leave unset for any caller (self-hosted `kubernetes-execution-mode`
* bootstrap, tests, admin routes) where an operator could realistically
* have edited the row through the normal environments UI/API.
*/
platformFullyManaged?: boolean;
}
export type ManagedSandboxEnvironmentReconcileAction =
@ -347,6 +359,24 @@ export function environmentService(db: Db) {
? [input.companyId]
: await tx.select({ id: companies.id }).from(companies).then((rows) => rows.map((row) => row.id));
activityCompanyIds = companyIds;
// Take the sandbox-row lock BEFORE reading the stock bindings. Two
// passes can reconcile the same slot concurrently (the boot ensure and
// the async provider-recovery reactivation, or two app builds during a
// rolling deploy). Concurrent passes serialize on this lock, and under
// READ COMMITTED each later statement sees a fresh snapshot — so a pass
// that blocks here then reads the bindings the winning pass committed,
// not a snapshot from before it waited. Reading bindings first left a
// window where a waiting pass compared the winner's fresh row against
// its own stale binding hash, misclassified the mismatch as
// `operator_modified`, and — once `platformFullyManaged` turns that
// into an update — rolled the row back to its own older stock.
const sandboxRows = await tx
.select()
.from(environments)
.where(eq(environments.driver, "sandbox"))
.for("update");
const bindingConditions = and(
eq(builtInManagedResources.bundleKey, MANAGED_ENVIRONMENT_BUNDLE_KEY),
eq(builtInManagedResources.resourceKind, MANAGED_ENVIRONMENT_RESOURCE_KIND),
@ -359,11 +389,6 @@ export function environmentService(db: Db) {
trackingInitialized = bindings.length < companyIds.length;
const keys = managedMetadataKeys(desiredMetadata, bindings);
const sandboxRows = await tx
.select()
.from(environments)
.where(eq(environments.driver, "sandbox"))
.for("update");
let row = sandboxRows.find(
(candidate) => (candidate.metadata as Record<string, unknown> | null)?.managedByPaperclip === true,
) ?? sandboxRows.find((candidate) => candidate.name === input.name) ?? null;
@ -540,6 +565,39 @@ export function environmentService(db: Db) {
);
if (operatorReaffirmedArchive) stockStatus = "operator_modified";
// `platformFullyManaged` callers (currently: the PAPERCLIP_MANAGED_CONFIG
// applier) assert that nothing in their deployment can hand-edit this
// row — the product gives a cloud-harness tenant no path to it, unlike
// the general self-hosted contract this function otherwise protects
// (see "classifies operator drift" in environment-service.test.ts,
// which exercises a real operator edit and must keep winning). Under
// that assertion, a plain content-hash mismatch against a real prior
// binding can only be drift between two platform-driven reconciliation
// passes (e.g. a stock hash recorded by an older app build before a
// later stock field was added), never a customization to protect —
// apply it like any other stock-outdated row.
//
// Two things the bypass must never touch:
// - A row with NO matching binding. `row` can be a same-name sandbox
// row that was never Paperclip-managed (the fallback lookup above).
// It also reads as `operator_modified`, but there is no prior
// platform pass to have drifted from — adopting it would overwrite
// a tenant-created environment and stamp it managed. Require a
// binding for this exact row, so "prior binding" is enforced, not
// just documented.
// - Archive-reaffirmation. A `sandbox_image` update must never
// resurrect a row something else deliberately kept archived after
// Paperclip's own provider-unavailability archival, so that path
// still skips below regardless of this flag.
if (
input.platformFullyManaged &&
stockStatus === "operator_modified" &&
!operatorReaffirmedArchive &&
matchingBindings.length > 0
) {
stockStatus = "stock_update_available";
}
if (stockStatus === "operator_modified") {
const baseline = matchingBindings[0];
let baselineDefaults = baseline

View File

@ -1,3 +1,4 @@
import { createWorkspaceGitInspectionCache } from "./workspace-git-inspection-cache.js";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
@ -1268,7 +1269,12 @@ type WorkspaceOverviewIssueRow = WorkspaceOverviewLinkedIssue & {
executionWorkspaceId: string;
};
const inspectGitForDisplay = createWorkspaceGitInspectionCache(inspectGitCloseReadiness);
export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) {
const inspectDisplay = opts.inspectGitCloseReadiness
? createWorkspaceGitInspectionCache(opts.inspectGitCloseReadiness)
: inspectGitForDisplay;
const recoveryActionsSvc = issueRecoveryActionService(db);
const resolvePullRequestDetails = opts.resolvePullRequestDetails ?? createPullRequestMergeDetailsResolver(db);
const now = opts.now ?? (() => new Date());
@ -1487,7 +1493,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
async function hydrateWorkspace(row: ExecutionWorkspaceRow, runtimeServices: WorkspaceRuntimeService[] = []) {
const workspace = toExecutionWorkspace(row, runtimeServices);
const { git } = await (opts.inspectGitCloseReadiness ?? inspectGitCloseReadiness)(workspace);
const { git } = await inspectDisplay(workspace);
const assessment = await assessDelivery(row, git);
return toExecutionWorkspace(row, runtimeServices, assessment.deliveryState);
}

View File

@ -171,6 +171,7 @@ describe("applyManagedEnvironments", () => {
provider: "daytona",
config: { target: "us" },
stockVersion: "2026.720.0",
platformFullyManaged: true,
});
// The frozen parsed config must not leak into the service (the row's
// config is mutated downstream when the provider key is forced in).
@ -346,6 +347,7 @@ describe("applyManagedEnvironments", () => {
provider: "daytona",
config: { target: "us" },
stockVersion: "2026.720.0",
platformFullyManaged: true,
});
expect(handle.off).toHaveBeenCalledTimes(1);
});

View File

@ -285,6 +285,7 @@ export async function applyManagedEnvironments(
provider: spec.provider,
config: { ...spec.config },
stockVersion: managedConfig.catalogVersion,
platformFullyManaged: true,
})
.then((result) => {
logger.info(
@ -364,6 +365,7 @@ export async function applyManagedEnvironments(
provider: spec.provider,
config: { ...spec.config },
stockVersion: managedConfig.catalogVersion,
platformFullyManaged: true,
});
if (reconciliation.action === "skipped") skipped += 1;
else {

View File

@ -182,6 +182,10 @@ type RecoveryWakeupOptions = {
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
issueStateGuard?: {
statuses: string[];
assigneeAgentId: string;
};
};
type RecoveryWakeup = (
@ -1921,6 +1925,17 @@ export function recoveryService(
source: "automation",
triggerDetail: "system",
reason: input.reason,
// The sweep can combine an old in-progress issue snapshot with a newer
// successful run. Validate eligibility under the enqueue issue lock so
// completion or reassignment cannot create a redundant continuation.
...(input.source === "issue.productive_terminal_continuation_recovery"
? {
issueStateGuard: {
statuses: ["in_progress"],
assigneeAgentId: input.agentId,
},
}
: {}),
payload: withRecoveryContext(
{
issueId: input.issueId,

View File

@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { and, eq, or, sql } from "drizzle-orm";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { heartbeatRuns } from "@paperclipai/db";
import { REDACTED_EVENT_VALUE } from "../redaction.js";
@ -7,6 +7,8 @@ import { getSecretProvider } from "../secrets/provider-registry.js";
import type { StoredSecretVersionMaterial } from "../secrets/types.js";
const REGISTRY_KEY = "paperclipSecretRedactions";
// Project only the registry: run contexts can contain megabytes of prompt data.
const registrySnapshot = sql`jsonb_build_object('paperclipSecretRedactions', ${heartbeatRuns.contextSnapshot} -> 'paperclipSecretRedactions')`;
type RegistryEntry = {
fingerprintSha256: string;
@ -70,14 +72,14 @@ export function createRunSecretRedactionRegistry(db: Db) {
}
async function valuesForRun(companyId: string, runId: string) {
const rows = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
const rows = await db.select({ contextSnapshot: registrySnapshot })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
return valuesForRuns(rows);
}
async function valuesForIssue(companyId: string, issueId: string) {
const rows = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
const rows = await db.select({ contextSnapshot: registrySnapshot })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.companyId, companyId),
@ -116,6 +118,27 @@ export function createRunSecretRedactionRegistry(db: Db) {
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
});
},
redactForRuns: async <T extends { id: string }>(companyId: string, runs: T[]): Promise<T[]> => {
if (runs.length === 0) return [];
const rows = await db.select({ id: heartbeatRuns.id, contextSnapshot: registrySnapshot })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runs.map((run) => run.id))));
// Resolve each encrypted value once per request, but apply only each run's
// own registry. Do not retain plaintext secrets across requests.
const resolved = new Map<string, Promise<string>>();
const valuesByRun = new Map(await Promise.all(rows.map(async (row) => {
const values = await Promise.all(registryEntries(row.contextSnapshot).map((entry) => {
let value = resolved.get(entry.fingerprintSha256);
if (!value) {
value = provider.resolveVersion({ material: entry.material, externalRef: null });
resolved.set(entry.fingerprintSha256, value);
}
return value;
}));
return [row.id, values.sort((a, b) => b.length - a.length)] as const;
})));
return runs.map((run) => redactRegisteredSecretValues(run, valuesByRun.get(run.id) ?? []));
},
redactForRun: async <T>(companyId: string, runId: string, value: T): Promise<T> =>
redactRegisteredSecretValues(value, await valuesForRun(companyId, runId)),
redactForIssue: async <T>(companyId: string, issueId: string, value: T): Promise<T> =>

View File

@ -15,8 +15,10 @@ import {
isNotNull,
isNull,
lt,
lte,
max,
ne,
or,
sql,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@ -8015,26 +8017,32 @@ export function toolAccessService(
const staleAfterMs = input.staleAfterMs ?? 15 * 60 * 1000;
const limit = input.limit ?? 25;
const cutoff = new Date(generatedAt.getTime() - staleAfterMs);
const connections = await db
.select()
// Legacy plugin backfills use a remote transport as a placeholder, but
// their tools run in the plugin worker and have no remote MCP endpoint.
// Select only due IDs in SQL so each scheduler tick does not decode every
// active connection's config and credential metadata.
const due = await db
.select({ id: toolConnections.id })
.from(toolConnections)
.innerJoin(toolApplications, and(
eq(toolApplications.id, toolConnections.applicationId),
eq(toolApplications.companyId, toolConnections.companyId),
))
.where(
and(
eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"),
ne(toolConnections.transport, "chat_sdk"),
ne(toolApplications.type, "paperclip_plugin"),
or(isNull(toolConnections.healthCheckedAt), lte(toolConnections.healthCheckedAt, cutoff)),
),
)
.orderBy(
asc(toolConnections.healthCheckedAt),
sql`${toolConnections.healthCheckedAt} asc nulls first`,
asc(toolConnections.createdAt),
);
const due = connections
.filter(
(connection) =>
!connection.healthCheckedAt || connection.healthCheckedAt <= cutoff,
asc(toolConnections.id),
)
.slice(0, limit);
.limit(limit);
let healthy = 0;
let failed = 0;
const failedConnectionIds: string[] = [];

View File

@ -0,0 +1,30 @@
import type { ExecutionWorkspace } from "@paperclipai/shared";
/** Short-lived display cache only. Destructive operations must inspect afresh. */
export function createWorkspaceGitInspectionCache<T>(inspect: (workspace: ExecutionWorkspace) => Promise<T>) {
const entries = new Map<string, { expiresAt: number; promise: Promise<T> }>();
return (workspace: ExecutionWorkspace): Promise<T> => {
const key = JSON.stringify([
workspace.companyId, workspace.id, workspace.updatedAt, workspace.providerType,
workspace.providerRef, workspace.cwd, workspace.repoUrl, workspace.baseRef,
workspace.branchName, workspace.metadata,
]);
const now = Date.now();
const existing = entries.get(key);
if (existing && existing.expiresAt > now) return existing.promise;
for (const [candidate, entry] of entries) {
if (entry.expiresAt <= now) entries.delete(candidate);
}
if (entries.size >= 256) entries.delete(entries.keys().next().value!);
const entry = { expiresAt: Number.POSITIVE_INFINITY, promise: Promise.resolve().then(() => inspect(workspace)) };
entries.set(key, entry);
entry.promise = entry.promise.then((result) => {
entry.expiresAt = Date.now() + 5_000;
return result;
}, (error) => {
if (entries.get(key) === entry) entries.delete(key);
throw error;
});
return entry.promise;
};
}

View File

@ -1,7 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { injectCloudUiSnippet } from "./cloud-ui-snippet.js";
import { applyUiBranding } from "./ui-branding.js";
export function readBrandedStaticIndexHtml(uiDist: string): string {
return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"));
return injectCloudUiSnippet(applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")));
}

View File

@ -94,9 +94,11 @@ describe("the connect step's cards", () => {
expect(key!.className).toBe(code!.className);
});
it("masks a key and does not mask a one-time code", () => {
// A provider key is a credential that goes on living; a browser code is
// single-use and about to be pasted somewhere the customer can see.
it("masks only when asked", () => {
// The primitive leaves the choice to each card rather than guessing from
// the label. The key card asks, and so does the Claude card for its code —
// that call site is pinned by the wizard's paste test. What this pins is
// that asking is what does it, and that not asking shows the value.
render(
<>
<OnboardingCardField value="" onChange={() => {}} onSubmit={() => {}} />

View File

@ -360,7 +360,12 @@ export function OnboardingCardField({
disabled?: boolean;
label?: string;
placeholder?: string;
/** A provider key is a credential; a one-time browser code is not. */
/**
* Dots instead of the value. The key card asks for it because a provider key
* is a credential that goes on living. The Claude card asks too: its code
* stays in the field after the paste so the customer can see something
* landed, and that is all they need to see of it.
*/
masked?: boolean;
/**
* Take focus when the card opens.

View File

@ -2261,6 +2261,15 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
// why the `onboarding` chrome draws no success state of its own — the screen
// it would appear on is already gone.
onConnected?: () => void;
// The pasted code went to the server. Fires as the submit starts rather than
// when the login finishes, so a caller can show the work the moment the
// customer has done their part: the round trip to `onConnected` is a poll
// and a completion read, long enough to read as nothing having happened.
onCodeSubmitted?: () => void;
// A submitted code did not become a stored login — the submit was refused,
// the completion failed, or the session failed or ran out of time. The pair
// of `onCodeSubmitted`, so a caller that showed work can stop showing it.
onSubmitFailed?: () => void;
chrome?: AdapterLoginChrome;
/**
* The address the customer has to open, once the server has produced one.
@ -2268,9 +2277,9 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
* The one fact about a running login that the step needs outside the card:
* its own button is what sends the customer there, and a prompt arriving is
* what moves the step from waiting to ready. Everything else it needs the
* panel already does the paste submits itself, success is reported through
* `onConnected`, and the customer's own Cancel press is reported through
* `onCancel` so this stays a single value rather than a whole session
* panel already does the paste submits itself, and the submit and how it
* ended are reported through `onCodeSubmitted`, `onSubmitFailed` and
* `onConnected` so this stays a single value rather than a whole session
* handed upward.
*/
onPromptReady?: (authorizationUrl: string | null) => void;
@ -2804,6 +2813,8 @@ function SubmittedBrowserCodeLoginPanel({
onApplyStored,
autoStart,
onConnected,
onCodeSubmitted,
onSubmitFailed,
chrome = "panel",
onPromptReady,
}: AdapterLoginPanelProps) {
@ -2827,6 +2838,10 @@ function SubmittedBrowserCodeLoginPanel({
// True after the client wall-clock cap passes for the active login. The panel
// stops both polls and shows the timed-out state.
const [timedOut, setTimedOut] = useState(false);
// A code has gone to the server and has not yet come back as a stored login
// or a failure. The field is locked for that stretch: the step's button is
// saying "Connecting" above it, and a second paste would submit again.
const [codeSubmitted, setCodeSubmitted] = useState(false);
// True after the status poll returns 404. The server removes the row and the
// in-memory session at once on any non-stored terminal state, so a status 404
// means the login failed and the server cleaned up. The panel stops both
@ -2855,6 +2870,7 @@ function SubmittedBrowserCodeLoginPanel({
setCompletionFailed(false);
setTimedOut(false);
setStatusGone(false);
setCodeSubmitted(false);
completionStartedRef.current = false;
};
@ -3171,11 +3187,26 @@ function SubmittedBrowserCodeLoginPanel({
Boolean(authorizationUrl) &&
!isCompleting &&
isValidBrowserCode(trimmedCode) &&
!submitCode.isPending;
!submitCode.isPending &&
!codeSubmitted;
const onCodeSubmittedRef = useRef(onCodeSubmitted);
onCodeSubmittedRef.current = onCodeSubmitted;
const onSubmitFailedRef = useRef(onSubmitFailed);
onSubmitFailedRef.current = onSubmitFailed;
const handleSubmit = () => {
if (!canSubmit) return;
// A new attempt supersedes the last attempt's error, and has to: the
// failure report below watches for an error after a submit, and one left
// over from before it would end this attempt the moment it began.
setStartError(null);
submitCode.mutate(trimmedCode);
// Reported now, not when the login finishes. A stored login is a poll and a
// completion read away, long enough that a button still offering "Waiting
// for code" after the paste read as the paste not having registered.
setCodeSubmitted(true);
onCodeSubmittedRef.current?.();
// Onboarding keeps the code on screen; the panel still clears it.
//
// Clearing emptied the input in the same frame the paste landed, so on the
@ -3272,6 +3303,18 @@ function SubmittedBrowserCodeLoginPanel({
onConnectedRef.current?.();
}, [isStored]);
// The other end of `onCodeSubmitted`. Any of these after a submit means the
// code is not going to become a stored login, and a caller still showing
// "Connecting" would otherwise spin for good. Once per submit; the field
// unlocks with it. Not reset on success: the field stays locked through the
// hold that follows, rather than reopening under a button saying Connecting.
useEffect(() => {
if (!codeSubmitted) return;
if (!startError && !isFailure && !timedOut) return;
setCodeSubmitted(false);
onSubmitFailedRef.current?.();
}, [codeSubmitted, startError, isFailure, timedOut]);
const onPromptReadyRef = useRef(onPromptReady);
onPromptReadyRef.current = onPromptReady;
useEffect(() => {
@ -3324,7 +3367,11 @@ function SubmittedBrowserCodeLoginPanel({
onPaste={() => {
pastedRef.current = true;
}}
disabled={submitCode.isPending || isCompleting}
// Dots, not the code. It stays in the field after the paste so the
// customer can see something landed, and that is all they need to
// see of it.
masked
disabled={submitCode.isPending || isCompleting || codeSubmitted}
/>
)}
</OnboardingLoginCard>

View File

@ -248,7 +248,7 @@ export function Layout() {
{ devServer?: { enabled?: boolean } } | undefined;
return data?.devServer?.enabled ? 2000 : false;
},
refetchIntervalInBackground: true,
refetchIntervalInBackground: false,
});
const keyboardShortcutsEnabled =
useQuery({

View File

@ -235,7 +235,7 @@ export function Layout() {
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
return data?.devServer?.enabled ? 2000 : false;
},
refetchIntervalInBackground: true,
refetchIntervalInBackground: false,
});
const keyboardShortcutsEnabled = useQuery({
queryKey: queryKeys.instance.generalSettings,

View File

@ -2306,10 +2306,271 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// followed was an input that had just gone blank — reported from staging
// as the paste looking dropped, or the step looking stuck.
expect(field!.value).toBe("Q2RJ-E1YIF-authorization-code");
// As dots. The code is kept so the customer can see the paste landed,
// and that is all the field needs to show of it.
expect(field!.type).toBe("password");
// And the button answers the paste itself. The status here never reaches
// authenticated, so this is "Connecting" before any server confirmation —
// waiting for that left about a second of a button still reading
// "Waiting for code" after the code had gone in.
expect(
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim(),
).toBe("Connecting");
await act(async () => root.unmount());
});
it("does not hire on the paste alone, before the login is stored", async () => {
// "Connecting" appears at the paste now, ahead of the server confirming
// anything. The two-second hold used to start at that same moment, so
// moving one without the other would hire at the paste plus two seconds
// whether or not a credential existed. The status here stays pending, so
// the login is never stored.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 4; i++) await flushReact();
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
// The paste really did start Connecting; without this the assertion
// below would hold for a flow that never got that far.
expect(cta(), "the paste should have started Connecting").toBe("Connecting");
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, CONNECTED_HOLD_MS + 400));
});
for (let i = 0; i < 4; i++) await flushReact();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(cta()).toBe("Connecting");
await act(async () => root.unmount());
});
it("gives the button back when the pasted code is refused", async () => {
// The other half of answering the paste early: a button that says
// "Connecting" before the server answers has to stop saying it when the
// answer is no, or it spins on a login that is not coming.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockRejectedValueOnce(
new Error("That authorization code was not accepted."),
);
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(cta()).toBe("Sign in to Claude");
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 8; i++) await flushReact();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("That authorization code was not accepted.");
expect(cta()).toBe("Sign in to Claude");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("does not reopen the card when a pasted code fails after Back", async () => {
// The panel stays mounted through Back's exit, so its report of a failed
// submit can land mid-exit. Restoring the button there reopened the card
// the customer was leaving, without the address Back had cleared.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
let refuse: (error: Error) => void = () => {};
mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
refuse = reject;
}),
);
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 4; i++) await flushReact();
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1);
expect(cta(), "the paste should have started Connecting").toBe("Connecting");
// Hold the exit open so the refusal lands inside it. Without a
// `matchMedia` to ask, every beat collapses to zero and the exit would be
// over before the refusal arrived — which would pass for the wrong reason.
const realMatchMedia = window.matchMedia;
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
try {
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
refuse(new Error("That authorization code was not accepted."));
});
for (let i = 0; i < 4; i++) await flushReact();
// Still leaving: the button shows the step's resting face, not the
// sign-in it would have reopened.
expect(cta()).toBe("Next");
// And the exit finishes — the row is a question again. Waited in short
// slices, each its own `act`. One long `act` defers React's commits to
// its end, so a beat's timer fires on time but its phase only commits
// when the wait is over — and the next beat is scheduled only then. The
// exit crawls one step per wait and never gets back to the question.
for (let i = 0; i < 30; i++) {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 50));
});
}
expect(
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center"),
).toBe(false);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: realMatchMedia,
});
}
await act(async () => root.unmount());
});
it("does not hire when the login finishes after Back", async () => {
// The same window from the other side. A login can complete while Back's
// exit is still running, and reporting that success pulled the step back
// into "Connecting" and on into a hire the customer had backed away from.
// No paste needed: here the server has already authenticated, and the
// completion read is simply slow.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
status: "authenticated",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
let finishCompletion: (value: { storedSessionId: string }) => void = () => {};
mockAgentsApi.completeClaudeSetupTokenLogin.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishCompletion = resolve;
}),
);
const realMatchMedia = window.matchMedia;
try {
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
for (let i = 0; i < 6; i++) await flushReact();
// The completion read is out and has not answered, and the card is up.
expect(mockAgentsApi.completeClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(cta()).toBe("Sign in to Claude");
// Hold the exit open, as above, so the success lands inside it.
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
finishCompletion({ storedSessionId: "stored-1" });
});
for (let i = 0; i < 4; i++) await flushReact();
expect(cta()).toBe("Next");
// Past the exit, and past the full hold a late success would have
// started. In short slices, each its own `act`, for the reason given in
// the test above — and here it matters twice: a hire scheduled by a late
// "Connecting" is only scheduled once that phase commits, so one long
// `act` would hide the very hire this is looking for.
const slices = Math.ceil((CONNECTED_HOLD_MS + 1200) / 50);
for (let i = 0; i < slices; i++) {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 50));
});
}
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center"),
).toBe(false);
await act(async () => root.unmount());
} finally {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: realMatchMedia,
});
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
}
}, 15_000);
it("starts the sign-in on the first press, even when it changes the adapter", async () => {
// The regression this is here for. Picking a source sets the phase *and*
// the adapter, and a reset keyed on the adapter then put the phase

View File

@ -1204,6 +1204,21 @@ function OnboardingWizardInner({
connectPhase === "unwindRow";
const connectLinkVisible = connectPhase === "idle" || connectPhase === "unwindRow";
/**
* When "Connecting" started, and whether the login behind it has finished.
*
* Two facts because they now arrive at different times. The button says
* "Connecting" the moment a code is pasted, but the credential only exists
* once the server confirms it, a poll and a completion read later. The hire
* waits for both: the stored login, and two seconds of "Connecting" counted
* from the paste so a fast server still shows the state, and a slow one
* does not have the hold added on top of its own wait.
*/
const connectingSinceRef = useRef<number | null>(null);
const [connectCredentialStored, setConnectCredentialStored] = useState(false);
/** What the button was offering before a paste, for when the paste is refused. */
const phaseBeforeSubmitRef = useRef<ConnectPhase>("waiting");
/** A sign-in is running and has not succeeded. */
const connectStepLoggingIn =
connectStepNeedsLogin && connectPhase !== "idle" && connectPhase !== "connecting";
@ -1232,17 +1247,27 @@ function OnboardingWizardInner({
return () => clearTimeout(t);
}
if (connectPhase === "connecting") {
// Not before the login is stored. "Connecting" starts at the paste now,
// ahead of the server confirming anything, so a hire from here would go
// out against a source with no credential to run on.
if (!connectCredentialStored) return;
// No success state: the step advances. The hold is so "Connecting" is
// legible as a state rather than a flicker on the way out — a step that
// left the instant a paste landed would read as the paste having gone
// wrong.
// wrong. Counted from when "Connecting" appeared, so the time the server
// spent confirming counts toward it instead of being added to it.
//
// A beat rather than a bare timer because Back stays live through it. A
// dropped handle hired two seconds after the customer had backed out,
// landing them on Review having asked for the opposite; `handleGiveHeartbeat`
// has no notion of the phase and could not refuse it. Leaving the phase —
// Back, the step changing, unmount — now cancels the hire with it.
const t = setTimeout(() => void handleGiveHeartbeat(), CONNECTED_HOLD_MS);
const shownFor =
connectingSinceRef.current === null ? 0 : Date.now() - connectingSinceRef.current;
const t = setTimeout(
() => void handleGiveHeartbeat(),
Math.max(0, CONNECTED_HOLD_MS - shownFor),
);
return () => clearTimeout(t);
}
if (connectPhase === "unwindCard") {
@ -1264,7 +1289,7 @@ function OnboardingWizardInner({
return () => clearTimeout(t);
}
return;
}, [step, connectPhase, credentialMode, connectStepNeedsLogin]);
}, [step, connectPhase, credentialMode, connectStepNeedsLogin, connectCredentialStored]);
/**
* The button's four faces, and which of them can be pressed.
@ -1313,6 +1338,8 @@ function OnboardingWizardInner({
*/
function unwindConnectStep() {
setConnectAuthUrl(null);
connectingSinceRef.current = null;
setConnectCredentialStored(false);
// Where the reverse starts depends on how far the sequence got. Backing out
// during the collapse has no card to close and no room to give back.
// With no card open, the row is the whole of the unwind.
@ -1335,6 +1362,10 @@ function OnboardingWizardInner({
setConnectPhase("waiting");
return;
}
// The hold owns the hire once "Connecting" is showing. That now starts at
// the paste, before the credential exists, so Cmd+Enter here would hire
// against a source with nothing to run on.
if (connectPhase === "connecting") return;
if (connectStepLoggingIn) return;
void handleGiveHeartbeat();
}
@ -1460,6 +1491,8 @@ function OnboardingWizardInner({
setConnectPhase("idle");
setConnectAuthUrl(null);
setSourcePicked(false);
connectingSinceRef.current = null;
setConnectCredentialStored(false);
}, [step]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
@ -2654,10 +2687,58 @@ function OnboardingWizardInner({
// The prompt arriving is what ends the waiting beat.
if (url) setConnectPhase((p) => (p === "loading" ? "ready" : p));
}}
onCodeSubmitted={() => {
// The button reacts to the paste, not to the server.
// Waiting for the login to be stored left about a
// second of a button still reading "Waiting for code"
// after the code had already gone in.
phaseBeforeSubmitRef.current = connectPhase;
connectingSinceRef.current = Date.now();
setConnectCredentialStored(false);
setConnectPhase("connecting");
}}
onSubmitFailed={() => {
// Only while the button still says "Connecting". The
// panel stays mounted through Back's exit, so a failure
// that landed after Back restored the button and
// reopened the card the customer was leaving — without
// the address Back had cleared, so its sign-in could
// not even be pressed.
if (connectPhase !== "connecting") return;
// Refused, failed or timed out — the card says which.
// The button goes back to what it was offering rather
// than spinning on a login that is not coming.
connectingSinceRef.current = null;
setConnectCredentialStored(false);
setConnectPhase(
phaseBeforeSubmitRef.current === "ready" ? "ready" : "waiting",
);
}}
onConnected={() => {
// Not into a card the customer has left. The panel is
// still mounted through Back's exit, and a login that
// finished there pulled the step back into "Connecting"
// and on into a hire they had just backed away from.
// The login is stored either way; what this refuses is
// only the step moving forward after they chose to go.
if (
connectPhase !== "loading" &&
connectPhase !== "ready" &&
connectPhase !== "waiting" &&
connectPhase !== "connecting"
) {
return;
}
// The hold before the step advances is the phase's own
// beat, above, so that backing out during it cancels
// the hire.
// the hire. It counts from the paste when there was
// one, and from here for a login that finished without
// one — a resumed session, or a code handed out rather
// than pasted back.
if (connectingSinceRef.current === null) {
connectingSinceRef.current = Date.now();
}
setConnectCredentialStored(true);
setConnectPhase("connecting");
}}
onStored={() => {

View File

@ -1229,31 +1229,72 @@ describe("TaskChatThread runtime transcript selection", () => {
},
);
it("does not show a completed-response notice for a redundant cancelled continuation", () => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
linkedRuns={[
{
runId: "connection-continuation-skipped",
status: "cancelled",
errorCode: "issue_not_in_progress",
startedAt: null,
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
createdAt: "2026-09-07T18:00:00.000Z",
finishedAt: "2026-09-07T18:00:01.000Z",
},
]}
/>,
);
expect(container.textContent).not.toContain(
"The runner returned no user-facing response.",
);
expect(container.textContent).not.toContain("Run completed");
});
it.each([
["legacy", "issue_not_in_progress"],
["native", "issue_not_in_progress"],
["legacy", "issue_terminal_status"],
["native", "issue_terminal_status"],
] as const)(
"hides a redundant cancelled continuation (%s, %s)",
(runtimeMode, errorCode) => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
linkedRuns={[
{
runId: "connection-continuation-skipped",
runtimeMode,
status: "cancelled",
errorCode,
startedAt: null,
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
createdAt: "2026-09-07T18:00:00.000Z",
finishedAt: "2026-09-07T18:00:01.000Z",
},
]}
/>,
);
expect(container.textContent).not.toContain(
"The runner returned no user-facing response.",
);
expect(container.textContent).not.toContain("Run completed");
expect(container.textContent).not.toContain("Couldn't start");
expect(container.textContent).not.toContain("Run cancelled");
expect(container.textContent).not.toContain("before returning an answer");
},
);
it.each(["legacy", "native"] as const)(
"keeps a cancellation visible when the %s run had already started",
(runtimeMode) => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
linkedRuns={[
{
runId: "started-cancellation",
runtimeMode,
status: "cancelled",
errorCode: "issue_terminal_status",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
createdAt: "2026-09-07T18:00:00.000Z",
startedAt: "2026-09-07T18:00:00.500Z",
finishedAt: "2026-09-07T18:00:01.000Z",
},
]}
/>,
);
expect(container.textContent).toContain(
runtimeMode === "native" ? "Run cancelled" : "Stopped",
);
},
);
it("does not treat a progress comment as the final response of a failed native run", () => {
nativeTranscriptState.transcriptByRun.set("native-progress-failed", [

View File

@ -1384,6 +1384,18 @@ export function TaskChatThread(props: TaskChatThreadProps) {
if (liveRun && source.id === liveRun.id) continue;
const entries = transcriptByRun.get(source.id) ?? [];
const meta = linkedRunMetaById.get(source.id);
// A queued continuation can become unnecessary while another turn finishes
// the task. Keep that cancellation in the run log, not the conversation.
// Apply this before native stop markers are assembled as well.
if (
source.status === "cancelled" &&
entries.length === 0 &&
(meta?.errorCode === "issue_not_in_progress" ||
(meta?.errorCode === "issue_terminal_status" && !meta.startedAt))
) {
settledRunIds.add(source.id);
continue;
}
const acceptedSummary = acceptedSemanticResultSummary(meta?.resultJson);
const parsedSource = transcriptToTaskChatItems(entries, {
runId: source.id,
@ -1516,16 +1528,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
});
}
if (entries.length === 0) {
// A queued continuation cancelled after the task was completed or parked
// never produced a provider turn. Keep its record in the run log without
// presenting it as a completed chat response.
if (
source.status === "cancelled" &&
meta?.errorCode === "issue_not_in_progress"
) {
settledRunIds.add(source.id);
continue;
}
if (sourceIsPaperclipRunner && sourceYielded) {
settledRunIds.add(source.id);
continue;

View File

@ -78,6 +78,7 @@ describe("useLiveRunTranscripts", () => {
const OriginalWebSocket = globalThis.WebSocket;
beforeEach(() => {
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
FakeWebSocket.instances = [];
useQueryMock.mockClear();
logMock.mockReset();
@ -88,6 +89,45 @@ describe("useLiveRunTranscripts", () => {
afterEach(() => {
globalThis.WebSocket = OriginalWebSocket;
vi.restoreAllMocks();
vi.useRealTimers();
});
it("pauses hidden-tab reads and resumes at the retained log offset", async () => {
vi.useFakeTimers();
const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 42 });
const runs = [{ id: "run-1", status: "running", adapterType: "codex_local" }];
function Harness() {
useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
return null;
}
const container = document.createElement("div");
const root = createRoot(container);
try {
await act(async () => root.render(<Harness />));
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(logMock).not.toHaveBeenCalled();
expect(FakeWebSocket.instances).toHaveLength(0);
await act(async () => {
visibility.mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
});
expect(logMock).toHaveBeenCalledTimes(1);
await act(async () => {
visibility.mockReturnValue("hidden");
document.dispatchEvent(new Event("visibilitychange"));
});
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(logMock).toHaveBeenCalledTimes(1);
await act(async () => {
visibility.mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
});
expect(logMock).toHaveBeenLastCalledWith("run-1", 42, 256_000, expect.anything());
} finally {
await act(async () => root.unmount());
}
});
it("waits for a connecting socket to open before closing it during cleanup", async () => {

View File

@ -1,3 +1,4 @@
import { usePageVisibility } from "../../lib/page-visibility";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { readTranscriptRequest } from "./read-transcript-request";
import { useQuery } from "@tanstack/react-query";
@ -102,6 +103,7 @@ export function useLiveRunTranscripts({
}: UseLiveRunTranscriptsOptions) {
// Ticker consumers opt into the silent chunk-count cap; full task views use a
// byte budget that collapses (not discards) the oldest output when exceeded.
const { visible } = usePageVisibility();
const retentionBudget: ChunkRetentionBudget = useMemo(
() =>
typeof maxChunksPerRun === "number"
@ -293,6 +295,7 @@ export function useLiveRunTranscripts({
}, [normalizedRuns, pruneTick]);
useEffect(() => {
if (!visible) return;
const readableRuns = normalizedRuns.filter(canReadPersistedLog);
if (readableRuns.length === 0) return;
@ -383,10 +386,10 @@ export function useLiveRunTranscripts({
controller.abort();
if (interval !== null) window.clearInterval(interval);
};
}, [enableRealtimeUpdates, logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey, retryGeneration]);
}, [visible, enableRealtimeUpdates, logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey, retryGeneration]);
useEffect(() => {
if (!enableRealtimeUpdates) return;
if (!visible || !enableRealtimeUpdates) return;
if (!companyId || activeRunIds.size === 0) return;
let closed = false;
@ -515,7 +518,7 @@ export function useLiveRunTranscripts({
}
}
};
}, [activeRunIds, companyId, enableRealtimeUpdates, runById]);
}, [visible, activeRunIds, companyId, enableRealtimeUpdates, runById]);
const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>();

View File

@ -1,3 +1,4 @@
import { usePageVisibility } from "@/lib/page-visibility";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent, SummarySlotIssueRef } from "@paperclipai/shared";
@ -62,6 +63,7 @@ export function useSummaryDraftStream(
companyId: string | null | undefined,
generatingIssue: SummarySlotIssueRef | null,
): SummaryDraftStream {
const { visible } = usePageVisibility();
const issueId = generatingIssue?.id ?? null;
const [runId, setRunId] = useState<string | null>(null);
const [chunks, setChunks] = useState<RunLogChunk[]>([]);
@ -118,9 +120,14 @@ export function useSummaryDraftStream(
if (fallbackRunId) setRunId((current) => current ?? fallbackRunId);
}, [fallbackRunId]);
useEffect(() => {
logOffsetRef.current = 0;
pendingLogRowsRef.current = new Map();
}, [runId]);
// Live token deltas over the shared company-events socket.
useCompanyLiveEvent((event: LiveEvent) => {
if (!runId) return;
if (!visible || !runId) return;
if (event.type !== "heartbeat.run.log") return;
const payload = event.payload ?? {};
if (payload.runId !== runId) return;
@ -136,12 +143,12 @@ export function useSummaryDraftStream(
// Hydrate already-emitted output and fill any gaps from the persisted run log.
useEffect(() => {
if (!runId) return;
logOffsetRef.current = 0;
pendingLogRowsRef.current = new Map();
if (!visible || !runId) return;
let cancelled = false;
let reading = false;
const read = async () => {
if (reading || cancelled) return;
reading = true;
try {
const result = await heartbeatsApi.log(runId, logOffsetRef.current, LOG_READ_LIMIT_BYTES);
if (cancelled) return;
@ -153,6 +160,8 @@ export function useSummaryDraftStream(
}
} catch {
// Ignore transient/404 reads (log not yet flushed, run just started).
} finally {
reading = false;
}
};
@ -162,7 +171,7 @@ export function useSummaryDraftStream(
cancelled = true;
window.clearInterval(interval);
};
}, [runId, appendChunks]);
}, [visible, runId, appendChunks]);
const parse = useMemo(() => parseSummaryDraftStream(extractAssistantOutputText(chunks)), [chunks]);

View File

@ -434,6 +434,7 @@ function ConnectFlowPreview({
<OnboardingCardField
value={code}
onChange={setCode}
masked
disabled={phase === "connecting"}
onSubmit={() => {
if (isValidBrowserCode(code.trim())) {

View File

@ -181,6 +181,24 @@ describe("LiveUpdatesProvider socket run notification scope", () => {
})));
}
it("disconnects while hidden and reconciles active queries once on return", async () => {
await receiveStatus({ runId: "child-run", agentId: "child-agent", status: "running" });
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
const visibility = vi.spyOn(document, "visibilityState", "get");
await reactAct(async () => {
visibility.mockReturnValue("hidden");
document.dispatchEvent(new Event("visibilitychange"));
});
expect(sockets[0].onmessage).toBeNull();
invalidate.mockClear();
await reactAct(async () => {
visibility.mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
});
await vi.waitFor(() => expect(sockets).toHaveLength(2));
expect(invalidate).toHaveBeenCalledExactlyOnceWith({ type: "active" }, { cancelRefetch: false });
});
it.each(["parent-agent", "child-agent"])("shows an unrelated retryable failure without issueId for %s", async (agentId) => {
// Match the retryable broadcast from execution-status-delivery.ts: it has
// exact run identity but deliberately omits issueId and provider output.

View File

@ -1,3 +1,4 @@
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import {
createContext,
useCallback,
@ -1787,6 +1788,8 @@ export const __liveUpdatesTestUtils = {
};
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const { visible } = usePageVisibility();
const wasHidden = useRef(!visible);
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
@ -1853,7 +1856,17 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
}, [currentUserId]);
useEffect(() => {
if (!visible) {
wasHidden.current = true;
invalidationBatcher.dispose();
return;
}
if (!canConnectSocket || !liveCompanyId) return;
if (wasHidden.current) {
wasHidden.current = false;
// Reconcile events missed while hidden, including completed runs/issues.
void queryClient.invalidateQueries({ type: "active" }, { cancelRefetch: false });
}
let closed = false;
let reconnectAttempt = 0;
@ -1905,6 +1918,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
};
nextSocket.onmessage = (message) => {
if (!getPageVisibility().visible) return;
const raw = typeof message.data === "string" ? message.data : "";
if (!raw) return;
@ -1961,6 +1975,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
closeSocketQuietly(activeSocket, "provider_unmount");
};
}, [
visible,
invalidationBatcher,
queryClient,
coalescingClient,
liveCompanyId,
pushToast,

View File

@ -18,7 +18,17 @@ function fakeClient() {
describe("createInvalidationBatcher", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
it("marks queries stale without refetching when the tab hides before flush", async () => {
const { client } = fakeClient();
const batcher = createInvalidationBatcher(client);
const pending = batcher.schedule({ queryKey: ["dashboard", "c1"] });
vi.stubGlobal("document", { visibilityState: "hidden" });
await batcher.flush();
await pending;
expect(client.invalidateQueries).toHaveBeenCalledExactlyOnceWith({ queryKey: ["dashboard", "c1"], refetchType: "none" });
});
it("coalesces repeated invalidations of the same key into one call per window", () => {
const { client } = fakeClient();
@ -111,7 +121,7 @@ describe("createInvalidationBatcher", () => {
describe("createCoalescingQueryClient", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
it("batches invalidateQueries but passes other methods straight through", () => {
const setQueryData = vi.fn();

View File

@ -1,3 +1,4 @@
import { getPageVisibility } from "./page-visibility";
import type { InvalidateQueryFilters, QueryClient } from "@tanstack/react-query";
/**
@ -62,7 +63,9 @@ export function createInvalidationBatcher(
const filtersList = [...pending.values()];
pending.clear();
try {
await Promise.all(filtersList.map((filters) => queryClient.invalidateQueries(filters)));
await Promise.all(filtersList.map((filters) => queryClient.invalidateQueries(
getPageVisibility().visible ? filters : { ...filters, refetchType: "none" },
)));
} finally {
deferred?.resolve();
}

View File

@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { HeartbeatRun } from "@paperclipai/shared";
import { afterEach, expect, it, vi } from "vitest";
import { LogViewer } from "./AgentDetail";
import { LogViewer as ProductionLogViewer } from "./AgentDetail.production";
const { log, empty } = vi.hoisted(() => ({ log: vi.fn(), empty: [] }));
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: { log } }));
vi.mock("@tanstack/react-query", async (original) => ({
...await original<typeof import("@tanstack/react-query")>(),
useQuery: () => ({ data: empty }),
}));
vi.mock("../adapters", () => ({
getUIAdapter: () => null,
onAdapterChange: () => () => {},
buildTranscript: (lines: unknown[]) => lines,
}));
vi.mock("../components/transcript/RunTranscriptView", () => ({
RunTranscriptView: ({ entries }: { entries: Array<{ chunk: string }> }) => <div>{entries.map(line => line.chunk).join(" ")}</div>,
}));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => { vi.restoreAllMocks(); log.mockReset(); });
it.each([LogViewer, ProductionLogViewer])("retains legacy history and reads only the next offset on visibility recovery (%#)", async (Viewer) => {
const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
const row = (seq: number, chunk: string) => JSON.stringify({ seq, ts: `2026-09-10T12:00:0${seq}Z`, stream: "stdout", chunk }) + "\n";
const first = row(1, "retained history");
const second = row(2, "new output");
log.mockResolvedValueOnce({ content: first, nextOffset: first.length });
const run = { id: "run-1", companyId: "company-1", agentId: "agent-1", status: "succeeded", logRef: "log" } as HeartbeatRun;
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<Viewer run={run} adapterType="codex_local" />));
expect(container.textContent).toContain("retained history");
await act(async () => {
visibility.mockReturnValue("hidden");
document.dispatchEvent(new Event("visibilitychange"));
});
expect(log).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("retained history");
let complete!: (value: { content: string; nextOffset: number }) => void;
log.mockImplementationOnce(() => new Promise(resolve => { complete = resolve; }));
await act(async () => {
visibility.mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
});
expect(container.textContent).toContain("retained history");
expect(log).toHaveBeenLastCalledWith("run-1", first.length, expect.any(Number));
await act(async () => complete({ content: second, nextOffset: first.length + second.length }));
expect(container.textContent).toContain("retained history new output");
log.mockResolvedValueOnce({ content: first, nextOffset: first.length });
await act(async () => root.render(<Viewer run={{ ...run, id: "run-2" }} adapterType="codex_local" />));
expect(log).toHaveBeenLastCalledWith("run-2", 0, expect.any(Number));
expect(container.textContent).not.toContain("new output");
} finally {
await act(async () => root.unmount());
container.remove();
}
});

View File

@ -1,5 +1,7 @@
import { AgentCharacter } from "../components/AgentCharacter";
import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
@ -384,6 +386,7 @@ function runMetrics(run: HeartbeatRun) {
}
export type RunLogChunk = {
seq?: number;
ts: string;
stream: "stdout" | "stderr" | "system";
chunk: string;
@ -3714,13 +3717,20 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
/* ---- Log Viewer ---- */
function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
export function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
const { visible } = usePageVisibility();
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
const [logLines, setLogLines] = useState<Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }>>([]);
const [logLines, setLogLines] = useState<RunLogChunk[]>([]);
const [loading, setLoading] = useState(true);
const [logLoading, setLogLoading] = useState(!!run.logRef);
const [logError, setLogError] = useState<string | null>(null);
const [logOffset, setLogOffset] = useState(0);
const [logOffset, setLogOffsetState] = useState(0);
const logOffsetRef = useRef(0);
const setLogOffset = useCallback((next: number | ((previous: number) => number)) => {
logOffsetRef.current = typeof next === "function" ? next(logOffsetRef.current) : next;
setLogOffsetState(logOffsetRef.current);
}, []);
const logMergeRefs = useRef({ seenChunkKeys: new Set<string>(), trimmedSeqFloorByRun: new Map<string, number>() });
const [hasMoreLog, setHasMoreLog] = useState(false);
const [loadingMoreLog, setLoadingMoreLog] = useState(false);
const [isFollowing, setIsFollowing] = useState(false);
@ -3747,6 +3757,12 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return err instanceof ApiError && err.status === 404;
}
function appendLogLines(incoming: RunLogChunk[]) {
setLogLines((previous) => mergeRunLogChunks(run.id, previous, incoming.map((line) => ({
...line, dedupeKey: `log:${run.id}:${line.ts}:${line.stream}:${line.chunk}`,
})), logMergeRefs.current, isLive ? MAX_LIVE_LOG_LINES : Number.POSITIVE_INFINITY).chunks);
}
function appendLogContent(content: string, finalize = false) {
if (!content && !finalize) return;
const combined = `${pendingLogLineRef.current}${content}`;
@ -3757,18 +3773,18 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
pendingLogLineRef.current = "";
}
const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = [];
const parsed: RunLogChunk[] = [];
for (const line of split) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown; seq?: unknown };
const stream =
raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
if (!chunk) continue;
parsed.push({ ts, stream, chunk });
parsed.push({ ts, stream, chunk, seq: readChunkSeq(raw.seq) });
} catch {
// ignore malformed lines
}
@ -3777,9 +3793,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
if (parsed.length > 0) {
// Live runs stream forever, so cap the retained tail. Terminated runs are
// paginated by the user via "Load more log" and keep their full history.
setLogLines((prev) =>
isLive ? appendCapped(prev, parsed, MAX_LIVE_LOG_LINES) : [...prev, ...parsed],
);
appendLogLines(parsed);
}
}
@ -3877,17 +3891,23 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
setIsFollowing((prev) => (prev ? prev : true));
}, [events.length, logLines.length, isLive, getScrollContainer]);
// Fetch persisted shell log
// Reset only when the log source changes, never when visibility changes.
useEffect(() => {
let cancelled = false;
pendingLogLineRef.current = "";
logMergeRefs.current = { seenChunkKeys: new Set(), trimmedSeqFloorByRun: new Map() };
seenProgressLogLineKeysRef.current = new Set();
setLogLines([]);
setLogOffset(0);
setHasMoreLog(false);
setLoadingMoreLog(false);
setLogError(null);
}, [run.id, run.logRef, setLogOffset]);
// Fetch persisted shell log, retaining partial rows and offsets across hides.
useEffect(() => {
if (!visible) return;
let cancelled = false;
const offset = logOffsetRef.current;
if (!run.logRef && !shouldPollShellLog) {
setLogLoading(false);
return () => {
@ -3898,10 +3918,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
setLogLoading(true);
const load = async () => {
try {
const result = await heartbeatsApi.log(run.id, 0, RUN_LOG_PAGE_BYTES);
const result = await heartbeatsApi.log(run.id, offset, RUN_LOG_PAGE_BYTES);
if (cancelled) return;
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? result.content.length;
const next = result.nextOffset ?? offset + result.content.length;
setLogOffset(next);
setHasMoreLog(!shouldPollShellLog && result.nextOffset !== undefined);
} catch (err) {
@ -3921,7 +3941,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return () => {
cancelled = true;
};
}, [run.id, run.logRef, run.logBytes, shouldPollShellLog]);
}, [visible, run.id, run.logRef, run.logBytes, shouldPollShellLog]);
async function loadMorePersistedLog() {
if (loadingMoreLog || !hasMoreLog) return;
@ -3942,27 +3962,42 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
// Poll for live updates
useEffect(() => {
if (!isLive || isStreamingConnected) return;
if (!visible || !isLive || isStreamingConnected) return;
let pending = false;
let cancelled = false;
const interval = setInterval(async () => {
if (pending || cancelled || !getPageVisibility().visible) return;
pending = true;
const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
try {
const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100);
if (cancelled) return;
if (newEvents.length > 0) {
setEvents((prev) => appendCapped(prev, newEvents, MAX_LIVE_EVENTS));
}
} catch {
// ignore polling errors
} finally {
pending = false;
}
}, 2000);
return () => clearInterval(interval);
}, [run.id, isLive, isStreamingConnected, events]);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [visible, run.id, isLive, isStreamingConnected, events]);
// Poll shell log for running runs
useEffect(() => {
if (!shouldPollShellLog || isStreamingConnected) return;
if (!visible || !shouldPollShellLog || isStreamingConnected) return;
let pending = false;
let cancelled = false;
const interval = setInterval(async () => {
if (pending || cancelled || !getPageVisibility().visible) return;
pending = true;
try {
const result = await heartbeatsApi.log(run.id, logOffset, 256_000);
if (cancelled) return;
if (result.content) {
appendLogContent(result.content, result.nextOffset === undefined);
}
@ -3974,14 +4009,19 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
} catch (err) {
if (isRunLogUnavailable(err)) return;
// ignore polling errors
} finally {
pending = false;
}
}, 2000);
return () => clearInterval(interval);
}, [run.id, shouldPollShellLog, isStreamingConnected, logOffset]);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [visible, run.id, shouldPollShellLog, isStreamingConnected, logOffset]);
// Stream live updates from websocket (primary path for running runs).
useEffect(() => {
if (!isLive) return;
if (!visible || !isLive) return;
let closed = false;
let reconnectTimer: number | null = null;
@ -4025,7 +4065,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const streamRaw = asNonEmptyString(payload.stream);
const stream = streamRaw === "stderr" || streamRaw === "system" ? streamRaw : "stdout";
const ts = asNonEmptyString((payload as Record<string, unknown>).ts) ?? event.createdAt;
setLogLines((prev) => appendCapped(prev, [{ ts, stream, chunk }], MAX_LIVE_LOG_LINES));
appendLogLines([{ ts, stream, chunk, seq: readChunkSeq(payload.seq) }]);
return;
}
@ -4035,7 +4075,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const key = heartbeatProgressLogLineKey(line);
if (seenProgressLogLineKeysRef.current.has(key)) return;
seenProgressLogLineKeysRef.current.add(key);
setLogLines((prev) => appendCapped(prev, [line], MAX_LIVE_LOG_LINES));
appendLogLines([line]);
return;
}
@ -4100,7 +4140,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
socket.close(1000, "run_detail_unmount");
}
};
}, [isLive, run.companyId, run.id, run.agentId]);
}, [visible, isLive, run.companyId, run.id, run.agentId]);
const censorUsernameInLogs = useQuery({
queryKey: queryKeys.instance.generalSettings,

View File

@ -1,5 +1,7 @@
import { AgentCharacter } from "../components/AgentCharacter";
import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
@ -384,6 +386,7 @@ function runMetrics(run: HeartbeatRun) {
}
export type RunLogChunk = {
seq?: number;
ts: string;
stream: "stdout" | "stderr" | "system";
chunk: string;
@ -3796,13 +3799,20 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
/* ---- Log Viewer ---- */
function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
export function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
const { visible } = usePageVisibility();
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
const [logLines, setLogLines] = useState<Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }>>([]);
const [logLines, setLogLines] = useState<RunLogChunk[]>([]);
const [loading, setLoading] = useState(true);
const [logLoading, setLogLoading] = useState(!!run.logRef);
const [logError, setLogError] = useState<string | null>(null);
const [logOffset, setLogOffset] = useState(0);
const [logOffset, setLogOffsetState] = useState(0);
const logOffsetRef = useRef(0);
const setLogOffset = useCallback((next: number | ((previous: number) => number)) => {
logOffsetRef.current = typeof next === "function" ? next(logOffsetRef.current) : next;
setLogOffsetState(logOffsetRef.current);
}, []);
const logMergeRefs = useRef({ seenChunkKeys: new Set<string>(), trimmedSeqFloorByRun: new Map<string, number>() });
const [hasMoreLog, setHasMoreLog] = useState(false);
const [loadingMoreLog, setLoadingMoreLog] = useState(false);
const [isFollowing, setIsFollowing] = useState(false);
@ -3829,6 +3839,12 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return err instanceof ApiError && err.status === 404;
}
function appendLogLines(incoming: RunLogChunk[]) {
setLogLines((previous) => mergeRunLogChunks(run.id, previous, incoming.map((line) => ({
...line, dedupeKey: `log:${run.id}:${line.ts}:${line.stream}:${line.chunk}`,
})), logMergeRefs.current, isLive ? MAX_LIVE_LOG_LINES : Number.POSITIVE_INFINITY).chunks);
}
function appendLogContent(content: string, finalize = false) {
if (!content && !finalize) return;
const combined = `${pendingLogLineRef.current}${content}`;
@ -3839,18 +3855,18 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
pendingLogLineRef.current = "";
}
const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = [];
const parsed: RunLogChunk[] = [];
for (const line of split) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown; seq?: unknown };
const stream =
raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
if (!chunk) continue;
parsed.push({ ts, stream, chunk });
parsed.push({ ts, stream, chunk, seq: readChunkSeq(raw.seq) });
} catch {
// ignore malformed lines
}
@ -3859,9 +3875,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
if (parsed.length > 0) {
// Live runs stream forever, so cap the retained tail. Terminated runs are
// paginated by the user via "Load more log" and keep their full history.
setLogLines((prev) =>
isLive ? appendCapped(prev, parsed, MAX_LIVE_LOG_LINES) : [...prev, ...parsed],
);
appendLogLines(parsed);
}
}
@ -3959,17 +3973,23 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
setIsFollowing((prev) => (prev ? prev : true));
}, [events.length, logLines.length, isLive, getScrollContainer]);
// Fetch persisted shell log
// Reset only when the log source changes, never when visibility changes.
useEffect(() => {
let cancelled = false;
pendingLogLineRef.current = "";
logMergeRefs.current = { seenChunkKeys: new Set(), trimmedSeqFloorByRun: new Map() };
seenProgressLogLineKeysRef.current = new Set();
setLogLines([]);
setLogOffset(0);
setHasMoreLog(false);
setLoadingMoreLog(false);
setLogError(null);
}, [run.id, run.logRef, setLogOffset]);
// Fetch persisted shell log, retaining partial rows and offsets across hides.
useEffect(() => {
if (!visible) return;
let cancelled = false;
const offset = logOffsetRef.current;
if (!run.logRef && !shouldPollShellLog) {
setLogLoading(false);
return () => {
@ -3980,10 +4000,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
setLogLoading(true);
const load = async () => {
try {
const result = await heartbeatsApi.log(run.id, 0, RUN_LOG_PAGE_BYTES);
const result = await heartbeatsApi.log(run.id, offset, RUN_LOG_PAGE_BYTES);
if (cancelled) return;
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? result.content.length;
const next = result.nextOffset ?? offset + result.content.length;
setLogOffset(next);
setHasMoreLog(!shouldPollShellLog && result.nextOffset !== undefined);
} catch (err) {
@ -4003,7 +4023,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return () => {
cancelled = true;
};
}, [run.id, run.logRef, run.logBytes, shouldPollShellLog]);
}, [visible, run.id, run.logRef, run.logBytes, shouldPollShellLog]);
async function loadMorePersistedLog() {
if (loadingMoreLog || !hasMoreLog) return;
@ -4024,27 +4044,42 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
// Poll for live updates
useEffect(() => {
if (!isLive || isStreamingConnected) return;
if (!visible || !isLive || isStreamingConnected) return;
let pending = false;
let cancelled = false;
const interval = setInterval(async () => {
if (pending || cancelled || !getPageVisibility().visible) return;
pending = true;
const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
try {
const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100);
if (cancelled) return;
if (newEvents.length > 0) {
setEvents((prev) => appendCapped(prev, newEvents, MAX_LIVE_EVENTS));
}
} catch {
// ignore polling errors
} finally {
pending = false;
}
}, 2000);
return () => clearInterval(interval);
}, [run.id, isLive, isStreamingConnected, events]);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [visible, run.id, isLive, isStreamingConnected, events]);
// Poll shell log for running runs
useEffect(() => {
if (!shouldPollShellLog || isStreamingConnected) return;
if (!visible || !shouldPollShellLog || isStreamingConnected) return;
let pending = false;
let cancelled = false;
const interval = setInterval(async () => {
if (pending || cancelled || !getPageVisibility().visible) return;
pending = true;
try {
const result = await heartbeatsApi.log(run.id, logOffset, 256_000);
if (cancelled) return;
if (result.content) {
appendLogContent(result.content, result.nextOffset === undefined);
}
@ -4056,14 +4091,19 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
} catch (err) {
if (isRunLogUnavailable(err)) return;
// ignore polling errors
} finally {
pending = false;
}
}, 2000);
return () => clearInterval(interval);
}, [run.id, shouldPollShellLog, isStreamingConnected, logOffset]);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [visible, run.id, shouldPollShellLog, isStreamingConnected, logOffset]);
// Stream live updates from websocket (primary path for running runs).
useEffect(() => {
if (!isLive) return;
if (!visible || !isLive) return;
let closed = false;
let reconnectTimer: number | null = null;
@ -4107,7 +4147,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const streamRaw = asNonEmptyString(payload.stream);
const stream = streamRaw === "stderr" || streamRaw === "system" ? streamRaw : "stdout";
const ts = asNonEmptyString((payload as Record<string, unknown>).ts) ?? event.createdAt;
setLogLines((prev) => appendCapped(prev, [{ ts, stream, chunk }], MAX_LIVE_LOG_LINES));
appendLogLines([{ ts, stream, chunk, seq: readChunkSeq(payload.seq) }]);
return;
}
@ -4117,7 +4157,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const key = heartbeatProgressLogLineKey(line);
if (seenProgressLogLineKeysRef.current.has(key)) return;
seenProgressLogLineKeysRef.current.add(key);
setLogLines((prev) => appendCapped(prev, [line], MAX_LIVE_LOG_LINES));
appendLogLines([line]);
return;
}
@ -4182,7 +4222,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
socket.close(1000, "run_detail_unmount");
}
};
}, [isLive, run.companyId, run.id, run.agentId]);
}, [visible, isLive, run.companyId, run.id, run.agentId]);
const censorUsernameInLogs = useQuery({
queryKey: queryKeys.instance.generalSettings,