Merge origin/master for branch Storybook deployment
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
535ff2bce0
|
|
@ -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.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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"/);
|
||||
});
|
||||
|
|
@ -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 }); }
|
||||
});
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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; });
|
||||
}
|
||||
|
|
@ -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"
|
||||
}}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}}
|
||||
}]
|
||||
}
|
||||
|
|
@ -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/*"
|
||||
}]
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 .
|
||||
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
40
Dockerfile
40
Dockerfile
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
}
|
||||
});
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> };
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={() => {}} />
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ export function Layout() {
|
|||
{ devServer?: { enabled?: boolean } } | undefined;
|
||||
return data?.devServer?.enabled ? 2000 : false;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
const keyboardShortcutsEnabled =
|
||||
useQuery({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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[]>();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -434,6 +434,7 @@ function ConnectFlowPreview({
|
|||
<OnboardingCardField
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
masked
|
||||
disabled={phase === "connecting"}
|
||||
onSubmit={() => {
|
||||
if (isValidBrowserCode(code.trim())) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue