diff --git a/.github/scripts/authorize-storybook-deploy.cjs b/.github/scripts/authorize-storybook-deploy.cjs new file mode 100644 index 0000000000..73d8ae7b83 --- /dev/null +++ b/.github/scripts/authorize-storybook-deploy.cjs @@ -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."); + } +}; diff --git a/.github/scripts/publish-storybook.cjs b/.github/scripts/publish-storybook.cjs new file mode 100644 index 0000000000..e0578a4bc7 --- /dev/null +++ b/.github/scripts/publish-storybook.cjs @@ -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)); diff --git a/.github/scripts/storybook-destination.cjs b/.github/scripts/storybook-destination.cjs new file mode 100644 index 0000000000..681fe4bb36 --- /dev/null +++ b/.github/scripts/storybook-destination.cjs @@ -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(/Storybook preview + +\n`; +} +module.exports = { storybookDestination, branchIndex }; diff --git a/.github/scripts/tests/cloud-readiness.test.mjs b/.github/scripts/tests/cloud-readiness.test.mjs new file mode 100644 index 0000000000..25233a335b --- /dev/null +++ b/.github/scripts/tests/cloud-readiness.test.mjs @@ -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"/); +}); diff --git a/.github/scripts/tests/docker-disk-workflow.test.mjs b/.github/scripts/tests/docker-disk-workflow.test.mjs new file mode 100644 index 0000000000..414af38cfd --- /dev/null +++ b/.github/scripts/tests/docker-disk-workflow.test.mjs @@ -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 }); } + }); +} diff --git a/.github/scripts/tests/lockfile-refresh-workflows.test.mjs b/.github/scripts/tests/lockfile-refresh-workflows.test.mjs index 7c19cf2cb9..e0e9775c04 100644 --- a/.github/scripts/tests/lockfile-refresh-workflows.test.mjs +++ b/.github/scripts/tests/lockfile-refresh-workflows.test.mjs @@ -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 () => { diff --git a/.github/scripts/tests/release-runner-cache.test.mjs b/.github/scripts/tests/release-runner-cache.test.mjs new file mode 100644 index 0000000000..9e4b8fe715 --- /dev/null +++ b/.github/scripts/tests/release-runner-cache.test.mjs @@ -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/); +}); diff --git a/.github/scripts/verify-storybook.cjs b/.github/scripts/verify-storybook.cjs new file mode 100644 index 0000000000..fd70299f6a --- /dev/null +++ b/.github/scripts/verify-storybook.cjs @@ -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; }); +} diff --git a/.github/storybook-deploy/cloudfront-read-statement.json b/.github/storybook-deploy/cloudfront-read-statement.json new file mode 100644 index 0000000000..9294a97287 --- /dev/null +++ b/.github/storybook-deploy/cloudfront-read-statement.json @@ -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" + }} +} diff --git a/.github/storybook-deploy/trust-policy.json b/.github/storybook-deploy/trust-policy.json new file mode 100644 index 0000000000..dd4e400d9b --- /dev/null +++ b/.github/storybook-deploy/trust-policy.json @@ -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" + }} + }] +} diff --git a/.github/storybook-deploy/upload-policy.json b/.github/storybook-deploy/upload-policy.json new file mode 100644 index 0000000000..a18edf3699 --- /dev/null +++ b/.github/storybook-deploy/upload-policy.json @@ -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/*" + }] +} diff --git a/.github/workflows/cloud-artifacts.yml b/.github/workflows/cloud-artifacts.yml new file mode 100644 index 0000000000..53d8ab710b --- /dev/null +++ b/.github/workflows/cloud-artifacts.yml @@ -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" diff --git a/.github/workflows/cloud-readiness.yml b/.github/workflows/cloud-readiness.yml new file mode 100644 index 0000000000..df6c1dac61 --- /dev/null +++ b/.github/workflows/cloud-readiness.yml @@ -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" diff --git a/.github/workflows/docker-cloud.yml b/.github/workflows/docker-cloud.yml new file mode 100644 index 0000000000..14fe69f87f --- /dev/null +++ b/.github/workflows/docker-cloud.yml @@ -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:-}" + + # 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<> "$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, -cloud, + # sha--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" diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml new file mode 100644 index 0000000000..e9705c3b5e --- /dev/null +++ b/.github/workflows/docker-runner-check.yml @@ -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 . diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 19e9f37935..7f76a3894d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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:-}" - - # 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, -cloud, - # sha--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- diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 7193f38c84..79adee9229 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4edc35b836..2f510813fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/runner-chaos-evals.yml b/.github/workflows/runner-chaos-evals.yml index 3131959522..42f8d6f307 100644 --- a/.github/workflows/runner-chaos-evals.yml +++ b/.github/workflows/runner-chaos-evals.yml @@ -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: diff --git a/.github/workflows/storybook-deploy.yml b/.github/workflows/storybook-deploy.yml new file mode 100644 index 0000000000..a485d033b5 --- /dev/null +++ b/.github/workflows/storybook-deploy.yml @@ -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 diff --git a/.github/workflows/storybook-visual.yml b/.github/workflows/storybook-visual.yml index 9e7b60c132..4b3773615c 100644 --- a/.github/workflows/storybook-visual.yml +++ b/.github/workflows/storybook-visual.yml @@ -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 }} diff --git a/Dockerfile b/Dockerfile index b51a2cfa97..b349251238 100644 --- a/Dockerfile +++ b/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 \ diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index e28f3dd486..3528a0e4ce 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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--` 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/-/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/-/`. +`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. diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 01278a8343..757a2c3094 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -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-` 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--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. diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index e8131595f8..43b6fee7bd 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -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. diff --git a/doc/STORYBOOK-DEPLOYMENT.md b/doc/STORYBOOK-DEPLOYMENT.md new file mode 100644 index 0000000000..cbac54cdd0 --- /dev/null +++ b/doc/STORYBOOK-DEPLOYMENT.md @@ -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. diff --git a/doc/cloud-build-readiness.md b/doc/cloud-build-readiness.md new file mode 100644 index 0000000000..e5c45c1586 --- /dev/null +++ b/doc/cloud-build-readiness.md @@ -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`, 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. diff --git a/doc/cloud-ui-snippet.md b/doc/cloud-ui-snippet.md new file mode 100644 index 0000000000..28deab517c --- /dev/null +++ b/doc/cloud-ui-snippet.md @@ -0,0 +1,42 @@ +# Cloud UI snippet + +Cloud operators can set `PAPERCLIP_CLOUD_UI_SNIPPET` to an HTML snippet. +The server inserts it before `` 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 + +``` + +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. diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index b8d05bbb32..973d35c0e7 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -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 diff --git a/doc/preview-release-artifacts.md b/doc/preview-release-artifacts.md index 11f0c2808d..d7a5fe5279 100644 --- a/doc/preview-release-artifacts.md +++ b/doc/preview-release-artifacts.md @@ -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 `. 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 diff --git a/packages/adapter-utils/src/acpx-engine/constants.ts b/packages/adapter-utils/src/acpx-engine/constants.ts index bb515b21b7..663d19ec24 100644 --- a/packages/adapter-utils/src/acpx-engine/constants.ts +++ b/packages/adapter-utils/src/acpx-engine/constants.ts @@ -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", diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index ae0ff1aa15..e9c4f0a576 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -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((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((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(() => {}), + 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>, + deps: Partial = {}, ) { 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((resolve) => { + endEvents = resolve; + }); + let releaseCloseStream!: () => void; + const closeStreamGate = new Promise((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(() => {}), + 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", () => { diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index ab615987f0..3f537fedd1 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -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; /** * 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 | 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 | undefined; + let lossDeadlineTripped = false; + let resolveLossDeadline: (() => void) | undefined; + const lossDeadline = new Promise((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 => { const turn = activeTurn as AcpRuntimeTurn; const toolTitles = new Map(); - 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 => { + 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, @@ -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 diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 06b8ff781e..4ed16aa0de 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -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; } @@ -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 diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index c2246dc128..e02389e819 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -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); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 8b18a89039..9def9e3800 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -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 diff --git a/scripts/__tests__/run-vitest-stable-shard.test.mjs b/scripts/__tests__/run-vitest-stable-shard.test.mjs index 278d2cba2e..441657d173 100644 --- a/scripts/__tests__/run-vitest-stable-shard.test.mjs +++ b/scripts/__tests__/run-vitest-stable-shard.test.mjs @@ -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)); + } +}); diff --git a/scripts/__tests__/storybook-deploy.test.mjs b/scripts/__tests__/storybook-deploy.test.mjs new file mode 100644 index 0000000000..edbe830f13 --- /dev/null +++ b/scripts/__tests__/storybook-deploy.test.mjs @@ -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/); + } +}); diff --git a/scripts/cloud-readiness.mjs b/scripts/cloud-readiness.mjs new file mode 100644 index 0000000000..09ad83a938 --- /dev/null +++ b/scripts/cloud-readiness.mjs @@ -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; } +} diff --git a/scripts/general-server-shard-durations.json b/scripts/general-server-shard-durations.json index d4f71285ac..115a12669d 100644 --- a/scripts/general-server-shard-durations.json +++ b/scripts/general-server-shard-durations.json @@ -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, diff --git a/scripts/preview-artifacts.mjs b/scripts/preview-artifacts.mjs index 6a68f83492..6a6eff6fb2 100644 --- a/scripts/preview-artifacts.mjs +++ b/scripts/preview-artifacts.mjs @@ -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; } } diff --git a/scripts/preview-artifacts.test.mjs b/scripts/preview-artifacts.test.mjs index 746f246cf9..15ca6978f2 100644 --- a/scripts/preview-artifacts.test.mjs +++ b/scripts/preview-artifacts.test.mjs @@ -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< `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 }); } +}); diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index db92138b64..1e7a91df35 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -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, diff --git a/scripts/test-line-shard.mjs b/scripts/test-line-shard.mjs new file mode 100644 index 0000000000..0d954115fb --- /dev/null +++ b/scripts/test-line-shard.mjs @@ -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"); +} diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index ef1e4bbdae..55a74aa750 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -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 () => { diff --git a/server/src/__tests__/agent-secrets-routes.test.ts b/server/src/__tests__/agent-secrets-routes.test.ts index da0543c26c..60375c1400 100644 --- a/server/src/__tests__/agent-secrets-routes.test.ts +++ b/server/src/__tests__/agent-secrets-routes.test.ts @@ -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, diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 71e5e2f2eb..61ecfec616 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -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(); + const fixtureServices = new Set(); + 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 () => { diff --git a/server/src/__tests__/cloud-image-bundled-plugins.test.ts b/server/src/__tests__/cloud-image-bundled-plugins.test.ts index 464fd73308..0358db8f8b 100644 --- a/server/src/__tests__/cloud-image-bundled-plugins.test.ts +++ b/server/src/__tests__/cloud-image-bundled-plugins.test.ts @@ -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 diff --git a/server/src/__tests__/cloud-image-sentry.test.ts b/server/src/__tests__/cloud-image-sentry.test.ts index 6eb4d14777..25a55e4baf 100644 --- a/server/src/__tests__/cloud-image-sentry.test.ts +++ b/server/src/__tests__/cloud-image-sentry.test.ts @@ -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 }; diff --git a/server/src/__tests__/cloud-ui-snippet.test.ts b/server/src/__tests__/cloud-ui-snippet.test.ts new file mode 100644 index 0000000000..8d64f7182b --- /dev/null +++ b/server/src/__tests__/cloud-ui-snippet.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { injectCloudUiSnippet } from "../cloud-ui-snippet.js"; + +const html = '
'; +const snippet = ''; + +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("", `${snippet}\n`)); + 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 = ''; + const result = injectCloudUiSnippet(html, { + PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: script, + }); + expect(result).toContain(script); + expect(result).not.toContain("test-token"); + }); +}); diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index f013d30ec6..4794989feb 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -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", diff --git a/server/src/__tests__/docker-entrypoint.test.ts b/server/src/__tests__/docker-entrypoint.test.ts index 102eca1b8b..03d0e4acb9 100644 --- a/server/src/__tests__/docker-entrypoint.test.ts +++ b/server/src/__tests__/docker-entrypoint.test.ts @@ -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 diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index f26bebfdad..5167ae4037 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -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({ diff --git a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts index ba9fad4ecf..faf7f719c9 100644 --- a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts +++ b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts @@ -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) => { + let pendingWake!: ReturnType; + 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(); diff --git a/server/src/__tests__/helpers/runner-api-server.ts b/server/src/__tests__/helpers/runner-api-server.ts index f0eee5cad2..a071b36fe6 100644 --- a/server/src/__tests__/helpers/runner-api-server.ts +++ b/server/src/__tests__/helpers/runner-api-server.ts @@ -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"); diff --git a/server/src/__tests__/run-secret-redaction.test.ts b/server/src/__tests__/run-secret-redaction.test.ts index fa2dd60a06..a4f73bf15c 100644 --- a/server/src/__tests__/run-secret-redaction.test.ts +++ b/server/src/__tests__/run-secret-redaction.test.ts @@ -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"); + }); +}); diff --git a/server/src/__tests__/static-index-html.test.ts b/server/src/__tests__/static-index-html.test.ts index 1ad3c40501..9d21f8dfca 100644 --- a/server/src/__tests__/static-index-html.test.ts +++ b/server/src/__tests__/static-index-html.test.ts @@ -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"), "App"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", ''); + 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">\n'); + }); + 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); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 6d0f82998b..ab444a0878 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -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, diff --git a/server/src/__tests__/vitest-chat-shards.test.ts b/server/src/__tests__/vitest-chat-shards.test.ts new file mode 100644 index 0000000000..dd929f1dd8 --- /dev/null +++ b/server/src/__tests__/vitest-chat-shards.test.ts @@ -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); diff --git a/server/src/__tests__/workspace-git-inspection-cache.test.ts b/server/src/__tests__/workspace-git-inspection-cache.test.ts new file mode 100644 index 0000000000..f4703cc225 --- /dev/null +++ b/server/src/__tests__/workspace-git-inspection-cache.test.ts @@ -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); +}); diff --git a/server/src/app.ts b/server/src/app.ts index e608276890..66960748ef 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; diff --git a/server/src/cloud-ui-snippet.ts b/server/src/cloud-ui-snippet.ts new file mode 100644 index 0000000000..41f8732599 --- /dev/null +++ b/server/src/cloud-ui-snippet.ts @@ -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`); +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 196567fea2..32ee640941 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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) => { diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 393b24bbf7..84a13c77d6 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -105,6 +105,18 @@ export interface ManagedSandboxEnvironmentInput { extraMetadata?: Record; /** 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 | 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 diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 8fa2aaf2c6..c1ec53ffd6 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -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); } diff --git a/server/src/services/managed-environments.test.ts b/server/src/services/managed-environments.test.ts index 0edc50fd4d..2a38e6c076 100644 --- a/server/src/services/managed-environments.test.ts +++ b/server/src/services/managed-environments.test.ts @@ -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); }); diff --git a/server/src/services/managed-environments.ts b/server/src/services/managed-environments.ts index 65bd9ece49..d201728cb7 100644 --- a/server/src/services/managed-environments.ts +++ b/server/src/services/managed-environments.ts @@ -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 { diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index be14888d3b..0361621ec1 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -182,6 +182,10 @@ type RecoveryWakeupOptions = { requestedByActorType?: "user" | "agent" | "system"; requestedByActorId?: string | null; contextSnapshot?: Record; + 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, diff --git a/server/src/services/run-secret-redaction.ts b/server/src/services/run-secret-redaction.ts index 4ca7963e95..1dfbdbedc6 100644 --- a/server/src/services/run-secret-redaction.ts +++ b/server/src/services/run-secret-redaction.ts @@ -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 (companyId: string, runs: T[]): Promise => { + 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>(); + 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 (companyId: string, runId: string, value: T): Promise => redactRegisteredSecretValues(value, await valuesForRun(companyId, runId)), redactForIssue: async (companyId: string, issueId: string, value: T): Promise => diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index f276559b3f..1b704acf04 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -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[] = []; diff --git a/server/src/services/workspace-git-inspection-cache.ts b/server/src/services/workspace-git-inspection-cache.ts new file mode 100644 index 0000000000..89ed9403c7 --- /dev/null +++ b/server/src/services/workspace-git-inspection-cache.ts @@ -0,0 +1,30 @@ +import type { ExecutionWorkspace } from "@paperclipai/shared"; + +/** Short-lived display cache only. Destructive operations must inspect afresh. */ +export function createWorkspaceGitInspectionCache(inspect: (workspace: ExecutionWorkspace) => Promise) { + const entries = new Map }>(); + return (workspace: ExecutionWorkspace): Promise => { + 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; + }; +} diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts index 13fa592c09..7bbc16f4c1 100644 --- a/server/src/static-index-html.ts +++ b/server/src/static-index-html.ts @@ -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"))); } diff --git a/ui/src/components/AdapterLoginChrome.test.tsx b/ui/src/components/AdapterLoginChrome.test.tsx index df572a6040..8a2008e7f1 100644 --- a/ui/src/components/AdapterLoginChrome.test.tsx +++ b/ui/src/components/AdapterLoginChrome.test.tsx @@ -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( <> {}} onSubmit={() => {}} /> diff --git a/ui/src/components/AdapterLoginChrome.tsx b/ui/src/components/AdapterLoginChrome.tsx index b2d8416daa..a8028cf950 100644 --- a/ui/src/components/AdapterLoginChrome.tsx +++ b/ui/src/components/AdapterLoginChrome.tsx @@ -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. diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 4bb03b9759..1e22442fcc 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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} /> )} diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index 62af6916b6..fc5e5f01a6 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -248,7 +248,7 @@ export function Layout() { { devServer?: { enabled?: boolean } } | undefined; return data?.devServer?.enabled ? 2000 : false; }, - refetchIntervalInBackground: true, + refetchIntervalInBackground: false, }); const keyboardShortcutsEnabled = useQuery({ diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 704f3c8426..d305599f4c 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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, diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 15d35e13d5..01ef763576 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -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 diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 97681e6a52..6052a437af 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -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(null); + const [connectCredentialStored, setConnectCredentialStored] = useState(false); + /** What the button was offering before a paste, for when the paste is refused. */ + const phaseBeforeSubmitRef = useRef("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={() => { diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 533ef704d1..a5199d77af 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -1229,31 +1229,72 @@ describe("TaskChatThread runtime transcript selection", () => { }, ); - it("does not show a completed-response notice for a redundant cancelled continuation", () => { - render( - {}} - 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( + {}} + 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( + {}} + 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", [ diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 911a880593..6cf98e5b00 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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; diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx index e78b8077d8..e82c282df4 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx +++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx @@ -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()); + 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 () => { diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index a7fcd6bb8f..cce877edb7 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -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(); diff --git a/ui/src/components/useSummaryDraftStream.ts b/ui/src/components/useSummaryDraftStream.ts index 48fe9d09eb..4cd052dd63 100644 --- a/ui/src/components/useSummaryDraftStream.ts +++ b/ui/src/components/useSummaryDraftStream.ts @@ -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(null); const [chunks, setChunks] = useState([]); @@ -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]); diff --git a/ui/src/connect-flow-preview-main.tsx b/ui/src/connect-flow-preview-main.tsx index d3e29e42c6..221b0ce3c1 100644 --- a/ui/src/connect-flow-preview-main.tsx +++ b/ui/src/connect-flow-preview-main.tsx @@ -434,6 +434,7 @@ function ConnectFlowPreview({ { if (isValidBrowserCode(code.trim())) { diff --git a/ui/src/context/LiveUpdatesProvider.hook.test.tsx b/ui/src/context/LiveUpdatesProvider.hook.test.tsx index 9d1957a3fb..41546fb571 100644 --- a/ui/src/context/LiveUpdatesProvider.hook.test.tsx +++ b/ui/src/context/LiveUpdatesProvider.hook.test.tsx @@ -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. diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 5ddec714f8..b2513f318f 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -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, diff --git a/ui/src/lib/query-invalidation-batcher.test.ts b/ui/src/lib/query-invalidation-batcher.test.ts index 12ce094ac7..2fcb2976ed 100644 --- a/ui/src/lib/query-invalidation-batcher.test.ts +++ b/ui/src/lib/query-invalidation-batcher.test.ts @@ -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(); diff --git a/ui/src/lib/query-invalidation-batcher.ts b/ui/src/lib/query-invalidation-batcher.ts index d5967aff5c..9d8807ebec 100644 --- a/ui/src/lib/query-invalidation-batcher.ts +++ b/ui/src/lib/query-invalidation-batcher.ts @@ -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(); } diff --git a/ui/src/pages/AgentDetail.log-visibility.test.tsx b/ui/src/pages/AgentDetail.log-visibility.test.tsx new file mode 100644 index 0000000000..b2dca3a615 --- /dev/null +++ b/ui/src/pages/AgentDetail.log-visibility.test.tsx @@ -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(), + useQuery: () => ({ data: empty }), +})); +vi.mock("../adapters", () => ({ + getUIAdapter: () => null, + onAdapterChange: () => () => {}, + buildTranscript: (lines: unknown[]) => lines, +})); +vi.mock("../components/transcript/RunTranscriptView", () => ({ + RunTranscriptView: ({ entries }: { entries: Array<{ chunk: string }> }) =>
{entries.map(line => line.chunk).join(" ")}
, +})); +(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()); + 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()); + expect(log).toHaveBeenLastCalledWith("run-2", 0, expect.any(Number)); + expect(container.textContent).not.toContain("new output"); + } finally { + await act(async () => root.unmount()); + container.remove(); + } +}); diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx index ee6f8f262f..a64210b408 100644 --- a/ui/src/pages/AgentDetail.production.tsx +++ b/ui/src/pages/AgentDetail.production.tsx @@ -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([]); - const [logLines, setLogLines] = useState>([]); + const [logLines, setLogLines] = useState([]); const [loading, setLoading] = useState(true); const [logLoading, setLogLoading] = useState(!!run.logRef); const [logError, setLogError] = useState(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(), trimmedSeqFloorByRun: new Map() }); 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).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, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 18f768c9e2..fc833c2aa0 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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([]); - const [logLines, setLogLines] = useState>([]); + const [logLines, setLogLines] = useState([]); const [loading, setLoading] = useState(true); const [logLoading, setLogLoading] = useState(!!run.logRef); const [logError, setLogError] = useState(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(), trimmedSeqFloorByRun: new Map() }); 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).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,