Merge current master into PR 12785
This commit is contained in:
commit
ecb166a152
|
|
@ -79,7 +79,6 @@ each one).
|
|||
|
||||
## Hard rules
|
||||
|
||||
* **YOU DO NOT MERGE THE PR YOURSELF. NEVER MERGE THE PR YOURSELF.**
|
||||
* Never lose work: no orphaned stashes, no dropped files, no force-pushes
|
||||
that discard commits.
|
||||
* Always post the URLs to every pull request you created.
|
||||
|
|
|
|||
|
|
@ -10,3 +10,35 @@ tmp
|
|||
*.log
|
||||
packages/paperclip-runner/dist
|
||||
packages/paperclip-runner/runner/target
|
||||
packages/paperclip-runner/devtools
|
||||
packages/paperclip-runner/docs
|
||||
packages/paperclip-runner/examples
|
||||
packages/paperclip-runner/test
|
||||
packages/paperclip-runner/test-fixtures
|
||||
packages/paperclip-runner/test-support
|
||||
packages/paperclip-runner/**/*.md
|
||||
packages/paperclip-runner/**/*.spec.ts
|
||||
packages/paperclip-runner/**/*.spec.tsx
|
||||
packages/paperclip-runner/**/*.test.cjs
|
||||
packages/paperclip-runner/**/*.test.cts
|
||||
packages/paperclip-runner/**/*.test.js
|
||||
packages/paperclip-runner/**/*.test.jsx
|
||||
packages/paperclip-runner/**/*.test.mjs
|
||||
packages/paperclip-runner/**/*.test.mts
|
||||
packages/paperclip-runner/**/*.test.ts
|
||||
packages/paperclip-runner/**/*.test.tsx
|
||||
packages/paperclip-runner/runner/crates/*/tests
|
||||
packages/paperclip-runner/scripts/*-smoke.mjs
|
||||
# Exceptions (last match wins): the image build re-runs the runner's
|
||||
# generated-file drift checks, so their committed outputs and inputs must
|
||||
# survive the slimming above. 2026-09-04: the *.md rule stripped the
|
||||
# committed capability contract out of the context and every image build
|
||||
# on master failed its drift check — .github/docker-context-checks.Dockerfile
|
||||
# now guards this in PR CI.
|
||||
!packages/paperclip-runner/generated/**
|
||||
!packages/paperclip-runner/docs/capability-contract.md
|
||||
# check:runner-workflow-traceability access()es every regression test the
|
||||
# stress-traceability spec names — those are src/**/*.test.ts files, so
|
||||
# they must survive the *.test.ts rule above.
|
||||
!packages/paperclip-runner/src/**/*.test.ts
|
||||
!packages/paperclip-runner/src/**/*.test.tsx
|
||||
|
|
|
|||
|
|
@ -18,5 +18,9 @@ PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=paperclip-dev-tool-action-signing-secret-ch
|
|||
# PAPERCLIP_WORKSPACE_GIT_SCAN_TIMEOUT_MS=8000
|
||||
# PAPERCLIP_WORKSPACE_GIT_SCAN_CACHE_TTL_MS=10000
|
||||
|
||||
# HTTP adapters may call public HTTP(S) origins by default. Opt trusted private
|
||||
# origins in explicitly; entries are exact origins (scheme, host, and port).
|
||||
# PAPERCLIP_HTTP_ADAPTER_PRIVATE_ENDPOINT_ALLOWLIST=http://hooks.internal.example:8080
|
||||
|
||||
# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh)
|
||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
# Runs the runner's generated-file drift checks against the EXACT build
|
||||
# context the image builds see — same .dockerignore semantics — so a
|
||||
# context-slimming change that strips a committed build input fails the
|
||||
# pull request instead of every post-merge image build. (2026-09-04: a new
|
||||
# `packages/paperclip-runner/**/*.md` ignore rule stripped the committed
|
||||
# capability contract out of the context; every Docker build on master then
|
||||
# failed its drift check, and no cloud image published for eight hours
|
||||
# while PR CI stayed green.)
|
||||
#
|
||||
# Only checks whose compared output is independent of dependency versions
|
||||
# run here: ajv is installed for schema VALIDATION only (pinned to the
|
||||
# runner's declared range), while codegen checks like
|
||||
# generate-protocol-schema-module stay out — their emitted bytes vary with
|
||||
# the ajv release, so running them against a fresh install would raise
|
||||
# false drift alarms. Those still run inside the real image build, which
|
||||
# installs the locked dependency tree; the existence assertions below keep
|
||||
# their committed inputs and outputs covered by this probe regardless.
|
||||
#
|
||||
# node:24-slim — the runner requires Node >= 24.11 and the production
|
||||
# image builds on Node 24; the digest pin keeps the security gate's own
|
||||
# runtime immutable.
|
||||
FROM node:24-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e
|
||||
WORKDIR /context
|
||||
COPY . .
|
||||
# Committed artifacts the image build reads whose drift checks cannot run
|
||||
# here (they need the locked dependency tree or compiled dist/). Existence
|
||||
# in the context is the property this probe guards; content correctness is
|
||||
# the real build's job. If a path is intentionally removed from the repo,
|
||||
# update this list in the same PR.
|
||||
RUN test -f packages/paperclip-runner/generated/capability/semantic-tool-contracts.json \
|
||||
&& test -f packages/paperclip-runner/generated/semantic-action-catalog.json \
|
||||
&& test -f packages/paperclip-runner/spec/evals/stress-workflow-traceability.json \
|
||||
&& test -d packages/paperclip-runner/protocol/fixtures/replay
|
||||
# check:runner-workflow-traceability access()es every regression test its
|
||||
# spec names (it needs dist/ to RUN, so it cannot run here) — replicate
|
||||
# exactly its existence walk, driven by the spec itself so this never
|
||||
# needs a hand-maintained path list. (2026-09-04, second unmasking: the
|
||||
# *.test.ts ignore rule stripped src/contracts/native-execution.test.ts
|
||||
# and the image build failed there once the capability checks were fixed.)
|
||||
RUN node -e ' \
|
||||
const manifest = require("/context/packages/paperclip-runner/spec/evals/stress-workflow-traceability.json"); \
|
||||
const { accessSync } = require("node:fs"); \
|
||||
const { resolve } = require("node:path"); \
|
||||
let count = 0; \
|
||||
for (const finding of manifest.findings) \
|
||||
for (const path of finding.regressionTests) { \
|
||||
accessSync(resolve("/context/packages/paperclip-runner", path)); \
|
||||
count += 1; \
|
||||
} \
|
||||
console.log(`traceability regression-test paths present: ${count}`);'
|
||||
# ajv is installed in an isolated directory (the runner's own package.json
|
||||
# uses workspace: ranges npm cannot install from) and symlinked in so ESM
|
||||
# resolution finds it from the scripts' location.
|
||||
RUN AJV_RANGE="$(node -p "require('/context/packages/paperclip-runner/package.json').dependencies.ajv")" \
|
||||
&& mkdir /probe-deps && cd /probe-deps && npm init -y >/dev/null \
|
||||
&& npm install --ignore-scripts --no-audit --no-fund "ajv@${AJV_RANGE}" \
|
||||
&& ln -s /probe-deps/node_modules /context/packages/paperclip-runner/node_modules \
|
||||
&& cd /context/packages/paperclip-runner \
|
||||
&& node scripts/generate-capability-contract.mjs --check \
|
||||
&& node scripts/check-capability-inventory.mjs
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// Run before building, and again inside the protected deployment job on reruns.
|
||||
module.exports = async function authorizeStorybookDeploy({ github, context }) {
|
||||
const fail = (message) => { throw new Error(message); };
|
||||
if (context.repo.owner !== "paperclipai" || context.repo.repo !== "paperclip") {
|
||||
fail("Storybook publishing is restricted to paperclipai/paperclip.");
|
||||
}
|
||||
if (context.eventName !== "workflow_dispatch" || !context.ref.startsWith("refs/heads/")) {
|
||||
fail("Storybook publishing requires a manual run from a repository branch.");
|
||||
}
|
||||
|
||||
// The selected branch must never be able to add itself to the allowlist.
|
||||
const { data: repository } = await github.rest.repos.get(context.repo);
|
||||
const { data: file } = await github.rest.repos.getContent({
|
||||
...context.repo,
|
||||
path: ".github/CODEOWNERS",
|
||||
ref: repository.default_branch,
|
||||
});
|
||||
if (file.encoding !== "base64" || typeof file.content !== "string") {
|
||||
fail("Cannot read the default branch CODEOWNERS file.");
|
||||
}
|
||||
const owners = new Set();
|
||||
for (const line of Buffer.from(file.content, "base64").toString("utf8").split(/\r?\n/)) {
|
||||
const fields = line.split("#", 1)[0].trim().split(/\s+/);
|
||||
for (const owner of fields.slice(1)) {
|
||||
// Individual GitHub accounts only. Teams/email entries do not grant access.
|
||||
if (/^@[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(owner)) {
|
||||
owners.add(owner.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (owners.size === 0) fail("CODEOWNERS has no individual GitHub accounts.");
|
||||
for (const actor of [context.actor, process.env.GITHUB_TRIGGERING_ACTOR]) {
|
||||
if (!actor || !owners.has(actor.toLowerCase())) {
|
||||
fail(`Only default-branch CODEOWNERS may publish Storybook (${actor || "missing actor"}).`);
|
||||
}
|
||||
}
|
||||
|
||||
// A branch can edit its workflow. Require a GitHub-enforced CODEOWNER review
|
||||
// as well, so editing this check cannot grant an outsider deployment access.
|
||||
const { data: environment } = await github.rest.repos.getEnvironment({
|
||||
...context.repo,
|
||||
environment_name: "storybook-deploy",
|
||||
});
|
||||
const reviewers = environment.protection_rules
|
||||
?.find((rule) => rule.type === "required_reviewers")?.reviewers;
|
||||
if (environment.can_admins_bypass !== false || !reviewers?.length ||
|
||||
reviewers.some(({ type, reviewer }) => type !== "User" || !owners.has(reviewer.login.toLowerCase()))) {
|
||||
fail("storybook-deploy must require CODEOWNER reviewers and disable administrator bypass.");
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
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']);
|
||||
aws(['s3', 'cp', indexFile, `s3://${destination.bucket}/${destination.bookmarkPrefix}/index.html`,
|
||||
'--content-type', 'text/html; charset=utf-8', '--cache-control', 'no-cache,max-age=0,must-revalidate', '--only-show-errors']);
|
||||
const report = `[Branch Storybook](${destination.url})\n\n[This build](${destination.buildUrl})\n\nCommit: \`${destination.sha}\`\n`;
|
||||
const reportPath = path.join(process.env.RUNNER_TEMP, 'storybook-deployment.md');
|
||||
fs.writeFileSync(reportPath, report);
|
||||
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
|
||||
`url=${destination.url}\nbuild_url=${destination.buildUrl}\nreport_path=${reportPath}\n`);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
|
||||
console.log(JSON.stringify(destination));
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
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}`;
|
||||
// Use one reversible path segment: slashes and special characters become
|
||||
// ~HH UTF-8 bytes, so feature/foo and feature-foo never share a bookmark.
|
||||
let bookmarkKey = [...Buffer.from(branch)].map((byte) =>
|
||||
/[A-Za-z0-9_-]/.test(String.fromCharCode(byte))
|
||||
? String.fromCharCode(byte) : `~${byte.toString(16).toUpperCase().padStart(2, '0')}`).join('');
|
||||
// Reserve the existing hashed directories, including all immutable builds.
|
||||
bookmarkKey = bookmarkKey.replace(/-([a-f0-9]{16})$/, '~2D$1');
|
||||
// Keep arbitrarily long ref names within S3's object-key limit. ~long cannot
|
||||
// occur in the reversible encoding, whose escapes contain only hex digits.
|
||||
if (bookmarkKey.length > 900) bookmarkKey = `${bookmarkKey.slice(0, 800)}~long-${digest}`;
|
||||
const bookmarkPrefix = `storybook/branches/${bookmarkKey}`;
|
||||
return {
|
||||
branch, sha, bucket, branchKey, prefix, buildPrefix, bookmarkPrefix,
|
||||
url: `${base.origin}/${bookmarkPrefix}/`,
|
||||
legacyUrl: `${base.origin}/${prefix}/index.html`,
|
||||
buildUrl: `${base.origin}/${buildPrefix}/index.html`,
|
||||
};
|
||||
}
|
||||
|
||||
function branchIndex(buildUrl) {
|
||||
// The target is generated from a validated origin and ASCII path segments.
|
||||
const target = JSON.stringify(buildUrl).replace(/</g, '\\u003c');
|
||||
return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Storybook preview</title>
|
||||
<script>const target = new URL(${target}); target.search = location.search; target.hash = location.hash; location.replace(target.href);</script>
|
||||
<noscript><a href="${buildUrl}">Open this branch's Storybook</a></noscript></html>\n`;
|
||||
}
|
||||
module.exports = { storybookDestination, branchIndex };
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { waitForCloudArtifacts } from "../../../scripts/cloud-readiness.mjs";
|
||||
import { previewManifest } from "../../../scripts/preview-artifacts.mjs";
|
||||
|
||||
const sha = "a".repeat(40);
|
||||
const digest = `sha256:${"b".repeat(64)}`;
|
||||
const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
|
||||
function registry({ missing = new Set(), failure, wrongImage = false, wrongPackage = false } = {}) {
|
||||
return async (url) => {
|
||||
if (failure) return json({}, failure);
|
||||
if (url.startsWith("https://registry.npmjs.org/")) {
|
||||
const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
|
||||
if (missing.has(name.split("/")[1])) return json({}, 404);
|
||||
const pkg = previewManifest({ name, version: "0.0.0" }, sha);
|
||||
return json({ ...pkg, ...(wrongPackage ? { gitHead: "c".repeat(40) } : {}), dist: { integrity: "sha512-fixture", tarball: "https://registry.npmjs.org/fixture.tgz" } });
|
||||
}
|
||||
if (url.includes("/token?")) return json({ token: "fixture" });
|
||||
if (url.includes("/manifests/")) return missing.has("image") ? json({}, 404) : json({ config: { digest } });
|
||||
if (url.includes("/blobs/")) return json({ config: { Labels: { "org.opencontainers.image.revision": wrongImage ? "c".repeat(40) : sha } } });
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
};
|
||||
}
|
||||
|
||||
test("readiness requires the image and both exact-source packages on the successful poll", async () => {
|
||||
const missing = new Set(["image", "shared", "db"]);
|
||||
let clock = 0;
|
||||
const states = [];
|
||||
const result = await waitForCloudArtifacts(sha, {
|
||||
fetchImpl: registry({ missing }), now: () => clock, intervalMs: 10, timeoutMs: 100, log: (message) => states.push(message),
|
||||
sleep: async (ms) => {
|
||||
clock += ms;
|
||||
if (clock === 10) missing.delete("image");
|
||||
if (clock === 20) missing.delete("shared");
|
||||
if (clock === 30) { missing.delete("db"); missing.add("image"); }
|
||||
if (clock === 40) missing.delete("image");
|
||||
},
|
||||
});
|
||||
assert.equal(clock, 40, "an artifact disappearing before the final poll must prevent readiness");
|
||||
assert.deepEqual(result, { version: 1, sha, packageVersion: `0.0.0-preview.g${sha}` });
|
||||
assert.match(states.at(-1), /Cloud artifacts available/);
|
||||
});
|
||||
|
||||
test("missing artifacts time out with a precise inventory and bounded sleep", async () => {
|
||||
let clock = 0;
|
||||
const sleeps = [];
|
||||
await assert.rejects(waitForCloudArtifacts(sha, {
|
||||
fetchImpl: registry({ missing: new Set(["db"]) }), now: () => clock, timeoutMs: 25, intervalMs: 20, log: () => {},
|
||||
sleep: async (ms) => { sleeps.push(ms); clock += ms; },
|
||||
}), /timed out.*missing: db/);
|
||||
assert.deepEqual(sleeps, [20, 5]);
|
||||
});
|
||||
|
||||
for (const fixture of [{ failure: 403 }, { failure: 503 }, { wrongImage: true }, { wrongPackage: true }]) {
|
||||
test(`registry errors and identity mismatches fail without waiting: ${JSON.stringify(fixture)}`, async () => {
|
||||
await assert.rejects(waitForCloudArtifacts(sha, {
|
||||
fetchImpl: registry(fixture), sleep: async () => assert.fail("must not retry an invalid artifact or upstream error"), log: () => {},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
test("invalid source and timing configuration are rejected before registry access", async () => {
|
||||
const fetchImpl = async () => assert.fail("invalid inputs must not reach a registry");
|
||||
await assert.rejects(waitForCloudArtifacts("master", { fetchImpl }), /full immutable commit SHA/);
|
||||
for (const options of [{ timeoutMs: 0 }, { intervalMs: -1 }, { timeoutMs: Infinity }]) {
|
||||
await assert.rejects(waitForCloudArtifacts(sha, { ...options, fetchImpl }), /positive finite/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the versioned readiness job requires successful source, image and artifact jobs", () => {
|
||||
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
|
||||
assert.match(workflow, /push:\s*\n\s*branches: \[master\]/);
|
||||
assert.match(workflow, /group: cloud-readiness-\$\{\{ github.sha \}\}/);
|
||||
assert.match(workflow, /uses: \.\/\.github\/workflows\/release-verify.yml\s+with:\s+ref: \$\{\{ github.sha \}\}/);
|
||||
assert.match(workflow, /uses: \.\/\.github\/workflows\/docker-cloud.yml/);
|
||||
const ready = workflow.split(" ready:")[1];
|
||||
assert.match(ready, /name: Cloud deployable v1/);
|
||||
assert.match(ready, /needs: \[verify, image, artifacts\]/);
|
||||
assert.match(ready, /if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'/);
|
||||
assert.doesNotMatch(ready, /^\s*(?:if:.*always\(|continue-on-error:)/m);
|
||||
assert.doesNotMatch(workflow, /secrets: inherit|id-token: write|actions: write|checks: write|uses: .*@v\d\b/);
|
||||
const cloud = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(cloud, /^ push:/m, "the master image must build only once");
|
||||
const migrator = readFileSync(new URL("../../workflows/cloud-artifacts.yml", import.meta.url), "utf8");
|
||||
assert.match(migrator, /push:\s*\n\s*branches: \[master\]/);
|
||||
assert.match(migrator, /SOURCE_SHA: \$\{\{ github.sha \}\}/);
|
||||
assert.match(migrator, /gh workflow run release.yml .*--ref master/);
|
||||
assert.match(migrator, /--field channel=cloud-migrator/);
|
||||
assert.match(migrator, /--field source_ref="\$SOURCE_SHA"/);
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
|
||||
// Exercise the workflow's actual boolean expression. Its string comparisons and
|
||||
// boolean operators have the same results in JS for these canonical contexts.
|
||||
const expression = workflow.match(/^ runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
|
||||
assert.ok(expression, "cloud routing must remain an explicit job expression");
|
||||
const timeoutExpression = workflow.match(/^ timeout-minutes: \$\{\{ (.+) \}\}$/m)?.[1];
|
||||
assert.ok(timeoutExpression, "AWS jobs must finish before the Fleet instance lifetime");
|
||||
const fleet = "runs-on/fleet=paperclip-cloud-build-x64/env=public-ci";
|
||||
const base = { repository: "paperclipai/paperclip", repository_id: "1170821064", ref: "refs/heads/master", event_name: "push" };
|
||||
for (const { name, github = {}, enabled = "true", expected = "ubuntu-latest" } of [
|
||||
{ name: "canonical master push", expected: fleet },
|
||||
{ name: "manual master build", github: { event_name: "workflow_dispatch" }, expected: fleet },
|
||||
{ name: "disabled switch", enabled: "false" },
|
||||
{ name: "missing switch", enabled: "" },
|
||||
{ name: "invalid switch", enabled: "yes" },
|
||||
{ name: "fork", github: { repository: "someone/paperclip", repository_id: "123" } },
|
||||
{ name: "wrong repository identity", github: { repository_id: "123" } },
|
||||
{ name: "pull request", github: { event_name: "pull_request", ref: "refs/pull/123/merge" } },
|
||||
{ name: "privileged PR event", github: { event_name: "pull_request_target" } },
|
||||
{ name: "release tag", github: { ref: "refs/tags/v2026.911.0" } },
|
||||
{ name: "branch push", github: { ref: "refs/heads/feature" } },
|
||||
{ name: "manual branch build", github: { event_name: "workflow_dispatch", ref: "refs/heads/feature" } },
|
||||
{ name: "workflow completion event", github: { event_name: "workflow_run" } },
|
||||
]) {
|
||||
test(`cloud runner routing: ${name}`, () => {
|
||||
const context = { github: { ...base, ...github }, vars: { AWS_CLOUD_BUILDS_ENABLED: enabled } };
|
||||
assert.equal(runInNewContext(expression, context), expected);
|
||||
assert.equal(runInNewContext(timeoutExpression, context), expected === fleet ? 40 : 60);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 }); }
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const workflow = readFileSync(new URL("../../workflows/refresh-lockfile.yml", import.meta.url), "utf8");
|
||||
const nodeStep = workflow.split(" - name: Setup Node.js\n")[1]?.split(" - name:")[0];
|
||||
assert.ok(nodeStep, "the refresh workflow must set up Node");
|
||||
const input = (name) => nodeStep.match(new RegExp(`^ ${name}: (.+)$`, "m"))?.[1].trim();
|
||||
|
||||
// setup-node's explicit cache input enables a store cache independently of its
|
||||
// automatic npm detection. Disabling only automatic detection is insufficient.
|
||||
function cacheProvider(explicitCache, automaticCache, packageManager) {
|
||||
if (explicitCache) return explicitCache;
|
||||
if (automaticCache !== "false" && packageManager.startsWith("npm@")) return "npm";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const packageManager of ["pnpm@9.15.4", "npm@11.0.0"]) {
|
||||
test(`resolution-only refresh cannot write a package-store cache (${packageManager})`, () => {
|
||||
assert.match(workflow, /run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile/);
|
||||
assert.equal(
|
||||
cacheProvider(input("cache"), input("package-manager-cache"), packageManager),
|
||||
undefined,
|
||||
"a metadata-only job must not claim the shared cache key with an empty store",
|
||||
);
|
||||
// This is the original failure mode, even with automatic caching disabled.
|
||||
assert.equal(cacheProvider("pnpm", "false", packageManager), "pnpm");
|
||||
});
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -17,6 +18,7 @@ test('lockfile repair workflows resolve dependencies instead of updating metadat
|
|||
|
||||
assert.ok(repairCommands.length > 0, `${workflow} must contain a lockfile repair command`);
|
||||
for (const command of repairCommands) {
|
||||
assert.match(command, /--resolution-only/);
|
||||
assert.match(command, /--ignore-scripts/);
|
||||
assert.doesNotMatch(command, /--lockfile-only/);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
const fleet = "runs-on/fleet=paperclip-post-merge-x64/env=public-ci";
|
||||
const sha = "a".repeat(40);
|
||||
const base = {
|
||||
repository: "paperclipai/paperclip", repository_id: "1170821064",
|
||||
ref: "refs/heads/master", event_name: "push", sha,
|
||||
};
|
||||
const expectedJobs = {
|
||||
"cloud-readiness.yml": [],
|
||||
"cloud-artifacts.yml": ["dispatch_migrator"],
|
||||
"release-verify.yml": ["typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"],
|
||||
"runner-chaos-evals.yml": ["chaos_and_recovery"],
|
||||
"release.yml": ["plan_preview", "package_preview"],
|
||||
};
|
||||
for (const [file, expectedNames] of Object.entries(expectedJobs)) {
|
||||
const workflow = readFileSync(new URL(`../../workflows/${file}`, import.meta.url), "utf8");
|
||||
const jobs = [...workflow.matchAll(/^ ([a-z_]+):\n([\s\S]*?)(?=^ [a-z_]+:\n|(?![\s\S]))/gm)];
|
||||
const routed = jobs.filter(([, , body]) => body.includes(fleet));
|
||||
test(`${file}: all intended jobs carry the post-merge guard`, () => {
|
||||
assert.deepEqual(routed.map(([, name]) => name).sort(), [...expectedNames].sort());
|
||||
});
|
||||
for (const [, job, body] of routed) {
|
||||
const expression = body.match(/^ runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
|
||||
assert.ok(expression, `${file}/${job} must use an explicit runner expression`);
|
||||
const release = file === "release.yml";
|
||||
const checkRef = release || file === "release-verify.yml" || file === "runner-chaos-evals.yml";
|
||||
const inputs = { ref: sha, source_ref: sha, channel: "cloud-migrator" };
|
||||
const defaultContext = { ...base, event_name: release ? "workflow_dispatch" : "push" };
|
||||
const cases = [
|
||||
{ name: "exact master source", expected: fleet },
|
||||
{ name: "manual exact master source", github: { event_name: "workflow_dispatch" }, expected: fleet },
|
||||
{ name: "switch disabled", enabled: "false" },
|
||||
{ name: "switch absent", enabled: "" },
|
||||
{ name: "malformed switch", enabled: "yes" },
|
||||
{ name: "fork", github: { repository: "someone/paperclip", repository_id: "123" } },
|
||||
{ name: "repository renamed or transferred", github: { repository_id: "123" } },
|
||||
{ name: "unapproved PR", github: { event_name: "pull_request", ref: "refs/pull/1/merge" } },
|
||||
{ name: "PR event even with master ref", github: { event_name: "pull_request" } },
|
||||
{ name: "privileged PR event", github: { event_name: "pull_request_target" } },
|
||||
{ name: "workflow completion event", github: { event_name: "workflow_run" } },
|
||||
{ name: "repository dispatch", github: { event_name: "repository_dispatch" } },
|
||||
{ name: "scheduled caller", github: { event_name: "schedule" } },
|
||||
{ name: "branch workflow", github: { ref: "refs/heads/feature" } },
|
||||
{ name: "release tag", github: { ref: "refs/tags/v2026.911.0" } },
|
||||
];
|
||||
if (checkRef) {
|
||||
const key = release ? "source_ref" : "ref";
|
||||
for (const value of ["b".repeat(40), "refs/pull/1/head", "master", "feature", "v1.0.0", ""]) {
|
||||
cases.push({ name: `unverified source ${value || "(empty)"}`, inputs: { [key]: value } });
|
||||
}
|
||||
cases.push({ name: "missing source identity", github: { sha: "" }, inputs: { [key]: "" } });
|
||||
}
|
||||
if (release) {
|
||||
cases.push({ name: "preview of master", inputs: { channel: "preview" } });
|
||||
cases.push({ name: "stable release", inputs: { channel: "stable" } });
|
||||
}
|
||||
for (const { name, github = {}, inputs: overrides = {}, enabled = "true", expected = "ubuntu-latest" } of cases) {
|
||||
test(`${file}/${job}: ${name}`, () => {
|
||||
const context = { github: { ...defaultContext, ...github }, inputs: { ...inputs, ...overrides }, vars: { AWS_POST_MERGE_CI_ENABLED: enabled } };
|
||||
// These canonical contexts use boolean operators and string comparisons
|
||||
// whose results match GitHub's expression evaluation.
|
||||
assert.equal(runInNewContext(expression, context), expected);
|
||||
const timeout = body.match(/^ timeout-minutes: (.+)$/m)?.[1];
|
||||
assert.ok(timeout, "AWS jobs need a timeout below the 45-minute instance lifetime");
|
||||
const minutes = timeout.startsWith("${{") ? runInNewContext(timeout.slice(3, -2), context) : Number(timeout);
|
||||
if (expected === fleet) assert.ok(minutes > 0 && minutes < 45);
|
||||
if (release && job === "plan_preview") assert.equal(minutes, expected === fleet ? 10 : 360);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (file === "release.yml") {
|
||||
test("npm publisher always uses a GitHub-hosted runner", () => {
|
||||
const publisher = jobs.find(([ , job]) => job === "publish_preview")?.[2];
|
||||
assert.match(publisher, /^ runs-on: ubuntu-latest$/m);
|
||||
assert.match(publisher, /^ environment: npm-canary$/m);
|
||||
assert.match(publisher, /^ id-token: write$/m);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
test("Cloud readiness bookkeeping never waits for the AWS verification fleet", () => {
|
||||
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
|
||||
const bodies = new Map();
|
||||
for (const [name, needs] of [
|
||||
["artifacts", null],
|
||||
["source_verified", "[verify]"],
|
||||
["ready", "[verify, image, artifacts]"],
|
||||
]) {
|
||||
const body = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z_]+:|(?![\\s\\S]))`, "m"))?.[1];
|
||||
assert.ok(body, `missing ${name} job`);
|
||||
bodies.set(name, body);
|
||||
assert.match(body, /^ runs-on: ubuntu-latest$/m);
|
||||
assert.doesNotMatch(body, /^ +continue-on-error:|^ +if:.*always\(\)/m);
|
||||
assert.match(body, /^ if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'$/m);
|
||||
assert.match(body, /^ +SOURCE_SHA: \$\{\{ github.sha \}\}$/m);
|
||||
assert.equal(body.match(/^ needs: (.+)$/m)?.[1] ?? null, needs, `${name} prerequisites`);
|
||||
}
|
||||
assert.match(bodies.get("artifacts"), /^ run: node scripts\/cloud-readiness.mjs "\$SOURCE_SHA"$/m);
|
||||
assert.match(bodies.get("source_verified"), /^ run: node --test scripts\/cloud-source-verification.test.mjs$/m);
|
||||
assert.match(bodies.get("source_verified"), /echo "Cloud source verified v1: \$SOURCE_SHA"/);
|
||||
assert.match(bodies.get("ready"), /echo "Cloud deployable v1: \$SOURCE_SHA"/);
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const workflow = readFileSync(new URL("../../workflows/pr-trusted.yml", import.meta.url), "utf8");
|
||||
const jobs = [...workflow.matchAll(/^ ([a-z_][a-z_0-9]*):\n([\s\S]*?)(?=^ [a-z_][a-z_0-9]*:\n|$(?![\s\S]))/gm)];
|
||||
const installers = jobs.filter(([, , body]) => body.includes("run: pnpm install --frozen-lockfile"));
|
||||
|
||||
test("PR workflows restore dependency stores without creating branch copies", () => {
|
||||
assert.equal(installers.length, 7);
|
||||
assert.doesNotMatch(workflow, /^ +cache: pnpm$/m);
|
||||
assert.doesNotMatch(workflow, /uses: actions\/cache(?:@|\/save@)/);
|
||||
for (const [, job, body] of jobs) {
|
||||
for (const step of body.split(" - name:").filter((step) => step.includes("uses: actions/setup-node@"))) {
|
||||
assert.match(step, /package-manager-cache: false/, job);
|
||||
}
|
||||
}
|
||||
const policy = jobs.find(([, name]) => name === "policy")[2];
|
||||
assert.doesNotMatch(policy, /uses: actions\/cache|cache: pnpm/);
|
||||
});
|
||||
|
||||
for (const [, job, body] of installers) {
|
||||
test(`${job}: reuse master keys before restoring the resolved PR lockfile`, () => {
|
||||
const locate = body.indexOf(" - name: Locate pnpm store");
|
||||
const restore = body.indexOf(" - name: Restore pnpm store (read only)");
|
||||
const artifact = body.indexOf(" - name: Restore regenerated PR lockfile");
|
||||
const install = body.indexOf("run: pnpm install --frozen-lockfile");
|
||||
assert.ok(locate >= 0 && locate < restore && restore < artifact && artifact < install);
|
||||
const cache = body.slice(restore, artifact);
|
||||
assert.match(body.slice(locate, restore), /pnpm store path --silent/);
|
||||
assert.match(body.slice(locate, restore), /node -p 'process.arch'/);
|
||||
assert.match(cache, /uses: actions\/cache\/restore@[a-f0-9]{40}/);
|
||||
assert.ok(cache.includes("key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}"));
|
||||
assert.ok(cache.includes("restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-"));
|
||||
assert.match(body.slice(artifact, install), /if: needs.policy.outputs.lockfile_regenerated == '1'/);
|
||||
assert.match(body.slice(artifact, install), /name: pr-lockfile/);
|
||||
assert.doesNotMatch(body.slice(artifact, install), /continue-on-error/);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
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, "${{ matrix.lane == 'rust' && 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.replace("matrix.lane == 'rust' && ", ""));
|
||||
assert.doesNotMatch(runner, /cache-on-failure: true|cache-all-crates: true/);
|
||||
});
|
||||
|
||||
test("parallel lanes cover check:all exactly once and never bypass verification", () => {
|
||||
const scripts = JSON.parse(readFileSync(new URL("../../../packages/paperclip-runner/package.json", import.meta.url))).scripts;
|
||||
const checks = [...runner.matchAll(/^ checks: (.+)$/gm)].flatMap(([, value]) => value.split(" "));
|
||||
assert.deepEqual(checks, scripts["check:all"].split(" && ").map((command) => command.replace(/^pnpm run /, "")));
|
||||
assert.deepEqual([...runner.matchAll(/^ - lane: (.+)$/gm)].map(([, value]) => value), ["protocol", "rust"]);
|
||||
assert.match(runner, /fail-fast: false/);
|
||||
assert.doesNotMatch(runner, /max-parallel: 1|^ needs:|continue-on-error:/m);
|
||||
const verify = runner.split(" - name: Verify Paperclip Runner\n")[1].split(" - name: Warm debug")[0];
|
||||
assert.match(verify, /RUNNER_CHECKS: \$\{\{ matrix.checks \}\}/);
|
||||
assert.match(verify, /set -euo pipefail/);
|
||||
assert.match(verify, /for check in \$RUNNER_CHECKS; do\s+pnpm --filter @paperclipai\/paperclip-runner "\$check"\s+done/);
|
||||
assert.doesNotMatch(verify, /if:|cache-hit/);
|
||||
assert.doesNotMatch(runner, /id-token: write|packages: write|secrets: inherit/);
|
||||
});
|
||||
|
||||
test("only the trusted Rust lane writes, and warms both build profiles before saving", () => {
|
||||
const cache = runner.split(" - name: Cache Runner Rust dependencies")[1].split(" - name: Install dependencies")[0];
|
||||
const warm = runner.split(" - name: Warm debug dependencies for the shared Runner cache")[1];
|
||||
const expr = (body, field) => body.match(new RegExp(`^ +${field}: \\$\\{\\{ (.+) \\}\\}$`, "m"))[1];
|
||||
assert.equal(expr(cache, "save-if"), expr(warm, "if"));
|
||||
assert.match(warm, /run: pnpm --filter @paperclipai\/paperclip-runner build:rust/);
|
||||
const sha = "a".repeat(40);
|
||||
const base = { repository: "paperclipai/paperclip", event_name: "push", ref: "refs/heads/master", sha };
|
||||
for (const lane of ["protocol", "rust"]) {
|
||||
for (const [overrides, ref, trusted] of [
|
||||
[{}, sha, true],
|
||||
[{ event_name: "pull_request", ref: "refs/pull/1/merge" }, sha, false],
|
||||
[{ event_name: "pull_request_target" }, sha, false],
|
||||
[{ event_name: "workflow_dispatch" }, sha, false],
|
||||
[{ repository: "someone/paperclip" }, sha, false],
|
||||
[{ ref: "refs/heads/feature" }, sha, false],
|
||||
[{}, "b".repeat(40), false],
|
||||
]) {
|
||||
const context = { matrix: { lane }, github: { ...base, ...overrides }, inputs: { ref } };
|
||||
assert.equal(runInNewContext(expr(cache, "if"), context), trusted);
|
||||
assert.equal(runInNewContext(expr(cache, "save-if"), context), trusted && lane === "rust");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
const workflow = readFileSync(new URL("../../workflows/release-verify.yml", import.meta.url), "utf8");
|
||||
const typecheck = workflow.split(" typecheck:\n")[1].split(" general_tests:\n")[0];
|
||||
const cache = typecheck.split(" - name: Cache typecheck Rust dependencies\n")[1].split(" - name: Validate release package manifest")[0];
|
||||
const sha = "a".repeat(40);
|
||||
const github = { repository: "paperclipai/paperclip", event_name: "push", ref: "refs/heads/master", sha };
|
||||
for (const [name, overrides, ref, allowed] of [
|
||||
["exact master push", {}, sha, true],
|
||||
["PR", { event_name: "pull_request", ref: "refs/pull/1/merge" }, sha, false],
|
||||
["privileged PR", { event_name: "pull_request_target" }, sha, false],
|
||||
["fork", { repository: "someone/paperclip" }, sha, false],
|
||||
["branch", { ref: "refs/heads/feature" }, sha, false],
|
||||
["manual source", { event_name: "workflow_dispatch" }, sha, false],
|
||||
["unmerged source", {}, "b".repeat(40), false],
|
||||
["moving ref", {}, "master", false],
|
||||
]) {
|
||||
test(`typecheck cache restore and save: ${name}`, () => {
|
||||
for (const field of ["if", "save-if"]) {
|
||||
const expr = cache.match(new RegExp(`^ +${field}: \\$\\{\\{ (.+) \\}\\}$`, "m"))?.[1];
|
||||
assert.ok(expr);
|
||||
assert.equal(runInNewContext(expr, { github: { ...github, ...overrides }, inputs: { ref } }), allowed);
|
||||
}
|
||||
});
|
||||
}
|
||||
test("cache excludes workspace code and executable installs, and preserves full checks", () => {
|
||||
assert.match(cache, /uses: Swatinem\/rust-cache@[a-f0-9]{40}/);
|
||||
assert.match(cache, /workspaces: packages\/paperclip-runner\/runner -> target/);
|
||||
assert.match(cache, /shared-key: release-typecheck-v1/);
|
||||
assert.match(cache, /cache-workspace-crates: false/);
|
||||
assert.match(cache, /cache-bin: false/);
|
||||
assert.ok(typecheck.indexOf('echo "RUSTUP_TOOLCHAIN=$toolchain"') < typecheck.indexOf("uses: Swatinem/rust-cache"));
|
||||
assert.match(typecheck, /run: pnpm -r typecheck/);
|
||||
assert.match(workflow, /shared-key: release-runner-v1/);
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
const { branchIndex } = require('./storybook-destination.cjs');
|
||||
|
||||
async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fetch,
|
||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), attempts = 6 }) {
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
const [metadata, index] = await Promise.all([
|
||||
fetch(new URL('deployment.json', buildUrl), { signal: AbortSignal.timeout(15000) }),
|
||||
fetch(branchUrl, { signal: AbortSignal.timeout(15000) }),
|
||||
]);
|
||||
if (!metadata.ok || !index.ok) throw new Error(`Public deployment returned HTTP ${metadata.status}/${index.status}.`);
|
||||
if ((await metadata.json()).sha !== sha) throw new Error('Public build has the wrong source commit.');
|
||||
if ((await index.text()) !== branchIndex(buildUrl)) throw new Error('Public branch URL does not point to this build.');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === attempts) throw error;
|
||||
await sleep(10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifyStorybook };
|
||||
if (require.main === module) {
|
||||
verifyStorybook({ branchUrl: process.env.BRANCH_URL, buildUrl: process.env.BUILD_URL,
|
||||
sha: process.env.SOURCE_SHA }).catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"Sid": "AllowCloudFrontReadStorybook",
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/storybook/branches/*",
|
||||
"Condition": {"StringEquals": {
|
||||
"AWS:SourceArn": "arn:aws:cloudfront::078455283791:distribution/E3GTU28BBO2SFR"
|
||||
}}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Federated": "arn:aws:iam::078455283791:oidc-provider/token.actions.githubusercontent.com"},
|
||||
"Action": "sts:AssumeRoleWithWebIdentity",
|
||||
"Condition": {"StringEquals": {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": "repo:paperclipai/paperclip:environment:storybook-deploy"
|
||||
}}
|
||||
}]
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload"],
|
||||
"Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/storybook/branches/*"
|
||||
}]
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
name: Cloud artifacts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dispatch_migrator:
|
||||
name: Start exact-source cloud migrator publication
|
||||
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
# This separate workflow starts at merge, outside the full npm release's
|
||||
# concurrency group. Publication stays in release.yml so npm recognizes
|
||||
# the established trusted-publisher identity and npm-canary environment.
|
||||
# No source checkout or package code runs with the dispatch credential.
|
||||
- name: Dispatch the migrator-only release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
request_id="$(cat /proc/sys/kernel/random/uuid)"
|
||||
gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref master \
|
||||
--field channel=cloud-migrator \
|
||||
--field source_ref="$SOURCE_SHA" \
|
||||
--field request_id="$request_id"
|
||||
echo "Started Cloud migrator $SOURCE_SHA in release.yml (request $request_id)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
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
|
||||
# Bookkeeping must not wait for the AWS builders it observes.
|
||||
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"
|
||||
|
||||
source_verified:
|
||||
# npm canary publication reuses this exact-source verification proof.
|
||||
# Keep it independent of image/migrator availability, and fail closed when
|
||||
# any source check fails, is cancelled, or is skipped.
|
||||
name: Cloud source verified v1
|
||||
needs: [verify]
|
||||
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
|
||||
# Bookkeeping must not wait for the AWS builders it observes.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Check the source verification consumer
|
||||
run: node --test scripts/cloud-source-verification.test.mjs
|
||||
- name: Record source verification
|
||||
env:
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
echo "Cloud source verified v1: $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
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'
|
||||
# Bookkeeping must not wait for the AWS builders it observes.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Record cloud readiness
|
||||
env:
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
echo "Cloud deployable v1: $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Source verification passed; the full-SHA image and exact-source migrator are available." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Deployment tooling must still resolve and pin the image and migrator and validate migration compatibility." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
name: Docker cloud
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions: {}
|
||||
|
||||
# Independent SHAs can build immediately on separate 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:
|
||||
# Only canonical master builds can consume the release Fleet. The runner
|
||||
# group must also allow this workflow only at refs/heads/master.
|
||||
# Keep an operator switch for a full-run retry on GitHub-hosted runners.
|
||||
runs-on: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-cloud-build-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
# Fleet instances expire after 45 minutes, including bootstrap and cleanup.
|
||||
timeout-minutes: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 40 || 60 }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# Full history and tags so `git describe` below can compute the
|
||||
# release version to stamp into the image.
|
||||
fetch-depth: 0
|
||||
|
||||
# `.git` is dockerignored, so a running image cannot derive its own
|
||||
# version and otherwise reports the source package.json placeholder in
|
||||
# analytics and the debug panel. Compute it here from the pristine
|
||||
# checkout (real CalVer drift from the nearest release tag) and pass it
|
||||
# into the build. Empty when no release tag is reachable — the server
|
||||
# then keeps its existing fallbacks.
|
||||
- name: Compute build version
|
||||
id: build-version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${GITHUB_REF}" in
|
||||
refs/tags/nightly/v*)
|
||||
# Lane tags carry the exact published version; stamp it verbatim
|
||||
# instead of describing drift from the nearest stable tag.
|
||||
version="${GITHUB_REF#refs/tags/nightly/v}"
|
||||
;;
|
||||
refs/tags/beta/v*)
|
||||
version="${GITHUB_REF#refs/tags/beta/v}"
|
||||
;;
|
||||
*)
|
||||
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
|
||||
;;
|
||||
esac
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "Stamping build version: ${version:-<none>}"
|
||||
|
||||
# ISO week stamp for the Dockerfile's tool layer: the layer caches
|
||||
# across commits and re-pulls the @latest CLI tools when the week rolls
|
||||
# over, instead of on every build.
|
||||
- name: Compute tool cache epoch
|
||||
id: tools-epoch
|
||||
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- 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
|
||||
|
||||
# Mixing several historical manifests missed otherwise reusable native
|
||||
# layers on fresh builders. Import the nearest available complete cache.
|
||||
- name: Select cloud cache ancestry
|
||||
id: cloud-cache
|
||||
env:
|
||||
CACHE_IMAGE: ghcr.io/${{ github.repository }}
|
||||
run: node scripts/select-cloud-cache.mjs
|
||||
|
||||
# Deployment tooling reads these labels from the registry to verify an
|
||||
# image's schema expectations against a migrator before deploying it,
|
||||
# without pulling the image. The server refuses to start when the
|
||||
# database is missing bundled migrations, so orchestrators need a cheap
|
||||
# way to check image/migrator compatibility up front.
|
||||
- name: Compute schema migration labels
|
||||
id: schema
|
||||
run: |
|
||||
set -euo pipefail
|
||||
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
|
||||
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
|
||||
echo "last=${last}" >> "$GITHUB_OUTPUT"
|
||||
echo "count=${count}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Published under the same lane tag set as the self-hosted image, with a
|
||||
# `-cloud` suffix (nightly-cloud, latest-cloud, <version>-cloud,
|
||||
# sha-<short>-cloud). `:canary-cloud` follows the same retag-step
|
||||
# ownership rule as `:canary` above.
|
||||
- name: Docker meta (cloud)
|
||||
id: meta-cloud
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-cloud,onlatest=true
|
||||
tags: |
|
||||
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
|
||||
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=sha
|
||||
labels: |
|
||||
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
|
||||
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
|
||||
|
||||
- name: Build and push (cloud)
|
||||
id: build-cloud
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
target: cloud
|
||||
# Space-separated sandbox-provider directory names to build into
|
||||
# the variant; add here when managed deployments need another.
|
||||
# CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the
|
||||
# variant installs from server/package.json's declared version;
|
||||
# add another name there when a managed tenant needs it.
|
||||
build-args: |
|
||||
USER_UID=1001
|
||||
USER_GID=1001
|
||||
CLOUD_BUNDLED_PLUGINS=daytona
|
||||
CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
|
||||
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
|
||||
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
|
||||
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
|
||||
# amd64 only, unlike the self-hosted image above: the cloud variant
|
||||
# is consumed exclusively by managed-deployment hosts, which run
|
||||
# amd64. The QEMU-emulated arm64 half dominated this job's wall
|
||||
# clock, and dropping it roughly halves time-to-deployable-image.
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
# Same-SHA builds serialize above; different SHAs never share a
|
||||
# writable cache ref. Registry layers are content-addressed and
|
||||
# shared even when cache manifests have separate tags.
|
||||
cache-from: ${{ steps.cloud-cache.outputs.source }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud-${{ github.sha }},mode=max
|
||||
tags: ${{ steps.meta-cloud.outputs.tags }}
|
||||
labels: ${{ steps.meta-cloud.outputs.labels }}
|
||||
|
||||
# The cloud target installs @sentry/node at the version
|
||||
# server/package.json declares, into a directory the server's own
|
||||
# module resolution walks. Verify the image this job just pushed, not
|
||||
# a local build, so a build-cache or layer-ordering regression is
|
||||
# caught before any tenant runs the image.
|
||||
|
||||
- name: Verify the pushed image resolves the declared Sentry version
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")"
|
||||
test -n "$expected"
|
||||
|
||||
installed="$(docker run --rm --pull always \
|
||||
-v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \
|
||||
--entrypoint node "$IMAGE" /app/server/.ci-sentry-probe.mjs)"
|
||||
|
||||
echo "Declared optional peer version: $expected"
|
||||
echo "Installed in the pushed image: $installed"
|
||||
if [ "$installed" != "$expected" ]; then
|
||||
echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "The pushed image resolves the declared @sentry/node version."
|
||||
|
||||
# Managed hosts run node as 1001:1001. Bake that identity into the image
|
||||
# so usermod does not walk the mounted home on every container start.
|
||||
# Check before the entrypoint can repair a wrongly built identity.
|
||||
- name: Verify cloud runtime user
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm --entrypoint sh "$IMAGE" -ec '
|
||||
test "$(id -u node)" = 1001
|
||||
test "$(id -g node)" = 1001
|
||||
test "$USER_UID" = 1001
|
||||
test "$USER_GID" = 1001
|
||||
'
|
||||
docker run --rm -e USER_UID=1001 -e USER_GID=1001 "$IMAGE" sh -ec '
|
||||
test "$(id -u)" = 1001
|
||||
test "$(id -g)" = 1001
|
||||
test -w "$PAPERCLIP_HOME"
|
||||
'
|
||||
|
||||
# Verify the independently published cloud image without waiting for
|
||||
# the self-hosted manifest job. The Sentry check already pulled it.
|
||||
- name: Verify cloud PID 1 reaps orphaned processes
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
|
||||
run: docker run --rm -i "$IMAGE" sh -s < scripts/assert-orphan-reaping.sh
|
||||
|
||||
# Cloud's commit resolver and preview-artifact planner use the full SHA.
|
||||
# Publish that address only after checking this build's exact digest.
|
||||
# Retagging reuses the registry manifest and does not rebuild the image.
|
||||
- name: Publish verified full-SHA cloud tag
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
|
||||
FULL_SHA_TAG: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}-cloud
|
||||
run: |
|
||||
set -euo pipefail
|
||||
revision="$(docker image inspect "$IMAGE" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')"
|
||||
platform="$(docker image inspect "$IMAGE" --format '{{ .Os }}/{{ .Architecture }}')"
|
||||
test "$revision" = "$GITHUB_SHA"
|
||||
test "$platform" = linux/amd64
|
||||
docker buildx imagetools create --prefer-index=false --tag "$FULL_SHA_TAG" "$IMAGE"
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
name: Docker Runner check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/docker-runner-check.yml
|
||||
- Dockerfile
|
||||
- .dockerignore
|
||||
- scripts/check-docker-runner-cache.sh
|
||||
- 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: 20
|
||||
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, then change source in a disposable context.
|
||||
# A fresh builder must import dependencies and produce changed binary metadata.
|
||||
# No registry credentials, external cache, or image publication.
|
||||
- name: Verify native build and dependency cache reuse
|
||||
run: bash scripts/check-docker-runner-cache.sh
|
||||
|
|
@ -14,21 +14,70 @@ on:
|
|||
# way.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# Least privilege: nothing at the workflow level; each job declares exactly
|
||||
# the token scopes it uses (checkout needs contents:read, GHCR pushes need
|
||||
# packages:write).
|
||||
permissions: {}
|
||||
|
||||
# Serialise builds per ref without killing an in-flight one: a newer push
|
||||
# supersedes only the pending slot, so the image build that is already
|
||||
# running always finishes and publishes.
|
||||
# running always finishes and publishes. Canary TAG refs each get their
|
||||
# own group on purpose: their builds run in parallel so every published
|
||||
# canary gets its sha images regardless of merge cadence. The mutable
|
||||
# `:canary` channel tags are NOT written by the build matrix (which
|
||||
# would race across parallel runs) — each canary-tag run retags the
|
||||
# channel afterwards, only if it still matches the npm `canary`
|
||||
# dist-tag, so the channel moves monotonically and always mirrors npm.
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Multi-arch by native runner, not QEMU.
|
||||
#
|
||||
# This was one job building linux/amd64,linux/arm64 together on an x86
|
||||
# runner. The arm64 half is emulated there, and it did not merely run slow:
|
||||
# it wedged, every time, in `RUN pnpm --filter @paperclipai/server build`,
|
||||
# emitting nothing for 38-45 minutes until `timeout-minutes: 60` killed the
|
||||
# job. Verified across three consecutive runs on 2026-09-04; the amd64 half
|
||||
# reached `production 5/5` minutes earlier in every one.
|
||||
#
|
||||
# A timed-out job is reported as *cancelled*, not failed, so the run read
|
||||
# "cancelled" and the production image simply stopped publishing without
|
||||
# anything going red in an obvious way.
|
||||
#
|
||||
# It also starved the queue. A run that burns the full hour holds the
|
||||
# top-level concurrency slot for that hour, and `cancel-in-progress: false`
|
||||
# keeps exactly one pending slot — so with merges arriving faster than one
|
||||
# an hour, most runs were superseded before they ever started a job. Five of
|
||||
# ten master commits sampled that day never produced an image at all.
|
||||
#
|
||||
# Each platform now builds on a runner of its own architecture and pushes by
|
||||
# digest; `merge` assembles the manifest list. arm64 is kept rather than
|
||||
# dropped (the cloud variant below dropped it and is amd64-only) because
|
||||
# this is the self-hosted image, and ARM hosts consume it.
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
# Independent legs: one architecture failing should still publish
|
||||
# nothing, but it must not also hide the other's logs behind a
|
||||
# cancellation.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# Native builds land well inside this; it is a backstop, not a budget.
|
||||
# (The interim fix while this PR landed raised the single QEMU job's cap
|
||||
# to 120 minutes; native per-arch legs make that headroom unnecessary.)
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
|
@ -87,7 +136,7 @@ jobs:
|
|||
- name: Refresh lockfile for Docker build context
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
changed="$(git status --porcelain)"
|
||||
if [ -z "$changed" ]; then
|
||||
|
|
@ -150,16 +199,18 @@ jobs:
|
|||
echo "last=${last}" >> "$GITHUB_OUTPUT"
|
||||
echo "count=${count}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Lane tag mapping: master pushes publish `:canary`, nightly/v* tags
|
||||
# publish `:nightly`, and only stable v* tags move `:latest` and the
|
||||
# versioned tags. `:sha-<short>` is published on every build.
|
||||
# Lane tag mapping: nightly/v* tags publish `:nightly`, and only
|
||||
# stable v* tags move `:latest` and the versioned tags.
|
||||
# `:sha-<short>` is published on every build. `:canary` is
|
||||
# deliberately absent here — the channel tag is moved by the
|
||||
# dist-tag-checked retag step below, never by the build matrix,
|
||||
# so parallel canary builds cannot race it backwards.
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
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') }}
|
||||
|
|
@ -169,8 +220,8 @@ jobs:
|
|||
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
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
|
|
@ -182,25 +233,123 @@ jobs:
|
|||
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
|
||||
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
|
||||
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
# By digest, not by tag: two runners cannot each push the same tag
|
||||
# and end up with a manifest list. Each leg publishes an untagged
|
||||
# image and `merge` names them together.
|
||||
outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
|
||||
# Registry-backed BuildKit cache instead of type=gha: the Actions
|
||||
# cache is capped at 10GB per repo, and two multi-arch mode=max jobs
|
||||
# evict each other, so most builds ran effectively cold. The cache
|
||||
# ref lives in ghcr next to the image and is written only by this
|
||||
# workflow (docker.yml runs on master/tag pushes, never on PRs).
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
#
|
||||
# Per-arch refs now the legs are separate runners: a shared ref would
|
||||
# have each leg overwrite the other's cache on every build.
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-${{ matrix.arch }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-${{ matrix.arch }},mode=max
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
# The digest is the only thing `merge` needs from this job. Carried as an
|
||||
# empty file named for it, which is the upstream pattern — the name is
|
||||
# the payload, so several legs can upload without colliding on content.
|
||||
- name: Export digest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
test -n "$digest"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digests-production-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Names the per-architecture digests as one manifest list under the real
|
||||
# tags. Nothing is publicly tagged until this runs, so a half-published
|
||||
# multi-arch image is not a state anything can pull.
|
||||
merge-and-push:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
# Checked out for `packages/db` (schema labels) and the orphan-reaping
|
||||
# script the verification step pipes in.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-production-*
|
||||
merge-multiple: true
|
||||
|
||||
- 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
|
||||
|
||||
# Repeated from the build job rather than passed between them: job
|
||||
# outputs would have to survive a matrix, and this is two `ls` calls.
|
||||
- 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"
|
||||
|
||||
# Same lane mapping as the build job; this is where it is actually
|
||||
# applied, since the legs push untagged. `:canary` is deliberately
|
||||
# absent, exactly as in the build job's mapping: the channel tag is
|
||||
# moved only by the dist-tag-checked promote_canary_channel job below,
|
||||
# so parallel canary-tag builds can never race the channel backwards.
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
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: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
# PID 1 must be an init that reaps adopted orphans. With node there, the
|
||||
# orphans agent runs leave behind are never wait()ed and pin as zombies
|
||||
# until the cgroup pid limit is exhausted and every fork() in the
|
||||
# container fails. Run against the pushed image rather than a local
|
||||
# build: the step above is multi-arch with `push: true`, so nothing is
|
||||
# loaded into the runner's daemon. The cloud variant is FROM production
|
||||
# and inherits the same ENTRYPOINT, so checking this image covers both.
|
||||
# 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 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
|
||||
|
|
@ -213,113 +362,47 @@ 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:
|
||||
if: github.event_name != 'push' || github.ref != 'refs/heads/master'
|
||||
uses: ./.github/workflows/docker-cloud.yml
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
# Moves the mutable `:canary` / `:canary-cloud` channel tags. Kept OUT
|
||||
# of the build jobs and serialized in its own lane, and — the load-
|
||||
# bearing property — CONVERGENT rather than self-interested: a
|
||||
# promotion does not promote "its own" canary, it retags the channel
|
||||
# to whatever the npm `canary` dist-tag names at execution time,
|
||||
# provided that version's sha images are published. GitHub's shared
|
||||
# concurrency lane keeps one running and one pending promotion and
|
||||
# REPLACES the pending slot with the latest enqueued — an older build
|
||||
# finishing late can therefore evict the newest canary's pending
|
||||
# promotion. With convergent promotion that eviction is harmless:
|
||||
# whichever promotion survives resolves the current dist-tag fresh
|
||||
# and lands the channel there (the current canary's images always
|
||||
# exist by the time any later promotion runs, because per-tag build
|
||||
# groups mean canary builds are never superseded and each run's
|
||||
# promotion is gated on its own completed pushes). Every interleaving
|
||||
# converges the Docker channel onto the npm channel.
|
||||
promote_canary_channel:
|
||||
if: startsWith(github.ref, 'refs/tags/canary/v')
|
||||
# merge-and-push, not build-and-push: the per-arch legs push untagged
|
||||
# digests, and the production `sha-*` tags this promotion retags only
|
||||
# exist once the manifest merge has named them.
|
||||
needs: [merge-and-push, build-and-push-cloud]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
concurrency:
|
||||
group: docker-canary-channel-promotion
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# Full history and tags so `git describe` below can compute the
|
||||
# release version to stamp into the image.
|
||||
fetch-depth: 0
|
||||
|
||||
# `.git` is dockerignored, so a running image cannot derive its own
|
||||
# version and otherwise reports the source package.json placeholder in
|
||||
# analytics and the debug panel. Compute it here from the pristine
|
||||
# checkout (real CalVer drift from the nearest release tag) and pass it
|
||||
# into the build. Empty when no release tag is reachable — the server
|
||||
# then keeps its existing fallbacks.
|
||||
- name: Compute build version
|
||||
id: build-version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${GITHUB_REF}" in
|
||||
refs/tags/nightly/v*)
|
||||
# Lane tags carry the exact published version; stamp it verbatim
|
||||
# instead of describing drift from the nearest stable tag.
|
||||
version="${GITHUB_REF#refs/tags/nightly/v}"
|
||||
;;
|
||||
refs/tags/beta/v*)
|
||||
version="${GITHUB_REF#refs/tags/beta/v}"
|
||||
;;
|
||||
*)
|
||||
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
|
||||
;;
|
||||
esac
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "Stamping build version: ${version:-<none>}"
|
||||
|
||||
# ISO week stamp for the Dockerfile's tool layer: the layer caches
|
||||
# across commits and re-pulls the @latest CLI tools when the week rolls
|
||||
# over, instead of on every build.
|
||||
- name: Compute tool cache epoch
|
||||
id: tools-epoch
|
||||
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
# No dependency cache here: this workflow publishes release images, and
|
||||
# restoring a shared Actions cache into the build inputs would let a
|
||||
# poisoned cache entry reach the published artifact.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Refresh lockfile for Docker build context
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm install --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:
|
||||
|
|
@ -327,99 +410,22 @@ jobs:
|
|||
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 (canary-cloud, nightly-cloud, latest-cloud,
|
||||
# <version>-cloud, sha-<short>-cloud).
|
||||
- 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=canary,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
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
|
||||
- name: Converge the channel tags onto the current npm canary
|
||||
env:
|
||||
IMAGE_TAGS: ${{ steps.meta-cloud.outputs.tags }}
|
||||
IMAGE: ghcr.io/${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
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
|
||||
current="$(curl -fsS "https://registry.npmjs.org/-/package/@paperclipai%2Fdb/dist-tags" | jq -er .canary)"
|
||||
sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/$(printf 'canary/v%s' "$current" | jq -sRr @uri)" --jq .sha 2>/dev/null || true)"
|
||||
if [ -z "$sha" ]; then
|
||||
echo "canary/v${current} does not resolve yet; a later promotion converges the channel"
|
||||
exit 0
|
||||
fi
|
||||
echo "The pushed image resolves the declared @sentry/node version."
|
||||
short="$(printf '%s' "$sha" | cut -c1-7)"
|
||||
if ! docker buildx imagetools inspect "$IMAGE:sha-${short}-cloud" >/dev/null 2>&1; then
|
||||
echo "images for ${current} (sha-${short}) not published yet; its own promotion converges the channel"
|
||||
exit 0
|
||||
fi
|
||||
docker buildx imagetools create -t "$IMAGE:canary" "$IMAGE:sha-${short}"
|
||||
docker buildx imagetools create -t "$IMAGE:canary-cloud" "$IMAGE:sha-${short}-cloud"
|
||||
echo "channel tags moved to canary ${current} (sha-${short})"
|
||||
|
|
|
|||
|
|
@ -280,17 +280,21 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Validate migration ordering against target branch
|
||||
run: >-
|
||||
|
|
@ -309,6 +313,13 @@ jobs:
|
|||
|
||||
- name: Test no-git-push check
|
||||
run: node --test ./scripts/check-no-git-push.test.mjs
|
||||
|
||||
- name: Validate feature module boundaries
|
||||
run: pnpm check:module-boundaries
|
||||
|
||||
- name: Test feature module boundary check
|
||||
run: node --test ./scripts/check-module-boundaries.test.mjs
|
||||
|
||||
- name: Test PR quality-gate scripts
|
||||
run: node --test '.github/scripts/tests/*.test.mjs'
|
||||
|
||||
|
|
@ -319,7 +330,7 @@ jobs:
|
|||
run: node --test ./scripts/__tests__/e2e-shard.test.mjs
|
||||
|
||||
- name: Test release verify workflow wiring
|
||||
run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs
|
||||
run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/cloud-source-verification.test.mjs
|
||||
|
||||
- name: Test standalone package build concurrency
|
||||
run: node --test ./scripts/__tests__/build-standalone-concurrency.test.mjs
|
||||
|
|
@ -337,7 +348,7 @@ jobs:
|
|||
id: regen_lockfile
|
||||
run: |
|
||||
cp pnpm-lock.yaml "$RUNNER_TEMP/pnpm-lock.before.yaml"
|
||||
pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
|
||||
if cmp -s "$RUNNER_TEMP/pnpm-lock.before.yaml" pnpm-lock.yaml; then
|
||||
echo "regenerated=0" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
|
|
@ -369,11 +380,36 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -381,12 +417,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -456,11 +486,36 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -468,12 +523,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -486,11 +535,40 @@ jobs:
|
|||
pnpm test:run:general -- --group '${{ matrix.group }}'
|
||||
fi
|
||||
|
||||
docker_context_integrity:
|
||||
name: Docker context integrity
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Not every runner the gate can select ships the Buildx plugin —
|
||||
# the image-build workflows set it up explicitly, so this lane does
|
||||
# too rather than failing before it checks anything.
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
# Same .dockerignore semantics as the real image builds: a
|
||||
# context-slimming change that strips a committed build input must
|
||||
# fail here, on the pull request, instead of failing every
|
||||
# post-merge image build. (2026-09-04: a new **/*.md ignore rule
|
||||
# stripped the committed capability contract out of the context;
|
||||
# every Docker build on master failed its drift check and no cloud
|
||||
# image published for eight hours while PR CI stayed green.)
|
||||
- name: Run generated-file drift checks against the Docker build context
|
||||
run: docker buildx build --file .github/docker-context-checks.Dockerfile .
|
||||
|
||||
verify:
|
||||
# Preserve the legacy required-check name while the underlying work runs in parallel.
|
||||
name: verify
|
||||
if: ${{ always() }}
|
||||
needs: [gate, policy, typecheck_release_registry, general_tests, build]
|
||||
needs: [gate, policy, typecheck_release_registry, general_tests, verify_paperclip_runner, build, docker_context_integrity]
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 5
|
||||
|
||||
|
|
@ -501,19 +579,25 @@ jobs:
|
|||
POLICY_RESULT: ${{ needs.policy.result }}
|
||||
TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }}
|
||||
GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }}
|
||||
RUNNER_VERIFICATION_RESULT: ${{ needs.verify_paperclip_runner.result }}
|
||||
BUILD_RESULT: ${{ needs.build.result }}
|
||||
DOCKER_CONTEXT_INTEGRITY_RESULT: ${{ needs.docker_context_integrity.result }}
|
||||
run: |
|
||||
test "$POLICY_RESULT" = "success"
|
||||
case "$FULL_CI" in
|
||||
true)
|
||||
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success"
|
||||
test "$GENERAL_TESTS_RESULT" = "success"
|
||||
test "$RUNNER_VERIFICATION_RESULT" = "success"
|
||||
test "$BUILD_RESULT" = "success"
|
||||
test "$DOCKER_CONTEXT_INTEGRITY_RESULT" = "success"
|
||||
;;
|
||||
false)
|
||||
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "skipped"
|
||||
test "$GENERAL_TESTS_RESULT" = "skipped"
|
||||
test "$RUNNER_VERIFICATION_RESULT" = "skipped"
|
||||
test "$BUILD_RESULT" = "skipped"
|
||||
test "$DOCKER_CONTEXT_INTEGRITY_RESULT" = "skipped"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid full_ci decision: $FULL_CI" >&2
|
||||
|
|
@ -521,6 +605,62 @@ jobs:
|
|||
;;
|
||||
esac
|
||||
|
||||
verify_paperclip_runner:
|
||||
name: Verify Paperclip Runner
|
||||
needs: [gate, policy]
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify Paperclip Runner
|
||||
run: pnpm --filter @paperclipai/paperclip-runner check:all
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: [gate, policy]
|
||||
|
|
@ -534,11 +674,36 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -546,17 +711,11 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify Paperclip Runner
|
||||
run: pnpm --filter @paperclipai/paperclip-runner check:all
|
||||
- name: Build Runner Evalbook viewer
|
||||
run: pnpm --filter @paperclipai/paperclip-runner build:issue-thread
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
|
@ -599,11 +758,36 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -611,12 +795,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -636,11 +814,36 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -648,12 +851,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -714,11 +911,36 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js for pnpm bootstrap
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -726,12 +948,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@ permissions:
|
|||
|
||||
jobs:
|
||||
ci:
|
||||
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@f038633bf5b04163ff985ef0542876bd9f455379
|
||||
# Pin: #13300 merge — restore-only dependency caches and parallel native verification.
|
||||
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@44dde2dec42a22746a2f36b595acacc9ccfa1df6
|
||||
|
|
|
|||
|
|
@ -32,10 +32,12 @@ jobs:
|
|||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
# Resolution-only installs do not populate the package store. Do not
|
||||
# claim the shared cache key with an empty archive before full installs.
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Refresh pnpm lockfile
|
||||
run: pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Fail on unexpected file changes
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ on:
|
|||
required: true
|
||||
type: string
|
||||
|
||||
# Caller-provided refs may name unmerged PR code. AWS is eligible only when
|
||||
# the caller runs on canonical master and verifies that event's exact SHA.
|
||||
# The organization group also restricts these workflow files to master.
|
||||
jobs:
|
||||
runner_chaos_evals:
|
||||
name: Pre-release Runner chaos evals
|
||||
|
|
@ -17,7 +20,7 @@ jobs:
|
|||
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -39,6 +42,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 typecheck 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-typecheck-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: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
|
|
@ -50,7 +77,7 @@ jobs:
|
|||
|
||||
general_tests:
|
||||
name: General tests (${{ matrix.group_label }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -58,16 +85,59 @@ 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 ten runners. Normal PR/local
|
||||
# invocations retain their complete general-server group.
|
||||
- group: general-server-without-chat
|
||||
group_label: server (1/10)
|
||||
shard_index: 0
|
||||
shard_count: 10
|
||||
- group: general-server-without-chat
|
||||
group_label: server (2/10)
|
||||
shard_index: 1
|
||||
shard_count: 10
|
||||
- group: general-server-without-chat
|
||||
group_label: server (3/10)
|
||||
shard_index: 2
|
||||
shard_count: 10
|
||||
- group: general-server-without-chat
|
||||
group_label: server (4/10)
|
||||
shard_index: 3
|
||||
shard_count: 10
|
||||
- group: general-server-without-chat
|
||||
group_label: server (5/10)
|
||||
shard_index: 4
|
||||
shard_count: 10
|
||||
- group: general-server-without-chat
|
||||
group_label: server (6/10)
|
||||
shard_index: 5
|
||||
shard_count: 10
|
||||
- group: general-server-without-chat
|
||||
group_label: server (7/10)
|
||||
shard_index: 6
|
||||
shard_count: 10
|
||||
- group: general-server-without-chat
|
||||
group_label: server (8/10)
|
||||
shard_index: 7
|
||||
shard_count: 10
|
||||
- group: general-server-without-chat
|
||||
group_label: server (9/10)
|
||||
shard_index: 8
|
||||
shard_count: 10
|
||||
- group: general-server-without-chat
|
||||
group_label: server (10/10)
|
||||
shard_index: 9
|
||||
shard_count: 10
|
||||
- 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
|
||||
|
|
@ -114,7 +184,7 @@ jobs:
|
|||
|
||||
serialized_tests:
|
||||
name: Serialized tests (${{ matrix.shard_label }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -163,41 +233,11 @@ jobs:
|
|||
|
||||
runner_workflow_evals:
|
||||
name: Runner workflow eval scorer contract
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run deterministic Runner workflow scorer tests
|
||||
run: pnpm test:runner-workflow-evals
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
|
@ -218,8 +258,110 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Run deterministic Runner workflow scorer tests
|
||||
run: pnpm test:runner-workflow-evals
|
||||
|
||||
verify_paperclip_runner:
|
||||
name: Verify Paperclip Runner (${{ matrix.lane }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- lane: protocol
|
||||
checks: check:eval-kernel check:protocol
|
||||
- lane: rust
|
||||
checks: check:runner check:api-authority
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
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
|
||||
# Both lanes restore the existing dependency cache. Only the Rust
|
||||
# lane saves it, after warming both release and debug dependencies.
|
||||
save-if: ${{ matrix.lane == 'rust' && github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Verify Paperclip Runner
|
||||
run: pnpm --filter @paperclipai/paperclip-runner check:all
|
||||
env:
|
||||
RUNNER_CHECKS: ${{ matrix.checks }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for check in $RUNNER_CHECKS; do
|
||||
pnpm --filter @paperclipai/paperclip-runner "$check"
|
||||
done
|
||||
|
||||
- name: Warm debug dependencies for the shared Runner cache
|
||||
# Protocol tests need debug binaries. Populate their dependencies in
|
||||
# the sole cache writer, so a cold save also serves the protocol lane.
|
||||
if: ${{ matrix.lane == 'rust' && github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
|
||||
run: pnpm --filter @paperclipai/paperclip-runner build:rust
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
name: 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:
|
||||
|
|
@ -17,12 +18,22 @@ on:
|
|||
- stable
|
||||
- beta
|
||||
- nightly
|
||||
- preview
|
||||
- cloud-migrator
|
||||
default: stable
|
||||
source_ref:
|
||||
description: (stable) Commit SHA, branch, or tag to publish as stable
|
||||
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/cloud-migrator) Correlation UUID
|
||||
type: string
|
||||
default: ""
|
||||
preview_migrator:
|
||||
description: (preview) Publish isolated shared and database packages if missing
|
||||
type: boolean
|
||||
default: false
|
||||
stable_date:
|
||||
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
|
||||
required: false
|
||||
|
|
@ -46,7 +57,7 @@ on:
|
|||
default: false
|
||||
|
||||
concurrency:
|
||||
group: release-${{ 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:
|
||||
|
|
@ -64,11 +75,252 @@ env:
|
|||
NPM_PUBLISH_VERIFY_DELAY_SECONDS: "10"
|
||||
|
||||
jobs:
|
||||
plan_preview:
|
||||
# Only the current master commit can use AWS. A preview or older source
|
||||
# falls back to GitHub-hosted runners, including raced merge dispatches.
|
||||
name: Check preview artifacts
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && (inputs.channel == 'preview' || inputs.channel == 'cloud-migrator') && !inputs.dry_run
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
# Preserve the previous hosted default; only AWS needs the Fleet limit.
|
||||
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 10 || 360 }}
|
||||
outputs:
|
||||
image: ${{ steps.plan.outputs.image }}
|
||||
packages: ${{ steps.plan.outputs.packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Validate immutable source and inspect existing artifacts
|
||||
id: plan
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_ref }}
|
||||
REQUEST_ID: ${{ inputs.request_id }}
|
||||
PREVIEW_MIGRATOR: ${{ inputs.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
|
||||
needs: plan_preview
|
||||
if: needs.plan_preview.outputs.packages == 'true'
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: trusted
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
path: source
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Install build dependencies without lifecycle scripts
|
||||
working-directory: source
|
||||
run: pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
- name: Build and pack exact-source preview packages
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_ref }}
|
||||
run: node trusted/scripts/preview-artifacts.mjs pack source packages "$SOURCE_SHA"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: preview-packages
|
||||
overwrite: true
|
||||
path: packages/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
publish_preview:
|
||||
# npm trusted publishing supports GitHub-hosted runners only.
|
||||
name: Publish preview migrator
|
||||
needs: [plan_preview, package_preview]
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Install npm with trusted publishing support
|
||||
run: npm install --global npm@11.18.0 --ignore-scripts
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: preview-packages
|
||||
path: preview-packages
|
||||
- name: Publish immutable preview packages without running package code
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_ref }}
|
||||
run: node scripts/preview-artifacts.mjs publish preview-packages "$SOURCE_SHA"
|
||||
|
||||
image_preview:
|
||||
name: Build preview cloud image
|
||||
needs: plan_preview
|
||||
if: needs.plan_preview.outputs.image == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Prepare locked image context
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
pnpm install --resolution-only --ignore-scripts --ignore-pnpmfile --no-frozen-lockfile
|
||||
echo "TOOLS_EPOCH=$(date -u +%G-W%V)" >> "$GITHUB_ENV"
|
||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
- name: Build the immutable cloud image without registry credentials
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
target: cloud
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
provenance: false # Docker archives cannot carry registry attestations.
|
||||
outputs: type=docker,dest=${{ runner.temp }}/preview-image.tar
|
||||
tags: ghcr.io/paperclipai/paperclip:sha-${{ inputs.source_ref }}-cloud
|
||||
build-args: |
|
||||
CLOUD_BUNDLED_PLUGINS=daytona
|
||||
CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
|
||||
PAPERCLIP_BUILD_COMMIT=${{ inputs.source_ref }}
|
||||
PAPERCLIP_BUILD_VERSION=0.0.0-preview.g${{ inputs.source_ref }}
|
||||
CLI_TOOLS_CACHE_EPOCH=${{ env.TOOLS_EPOCH }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ inputs.source_ref }}
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: preview-image
|
||||
overwrite: true
|
||||
path: ${{ runner.temp }}/preview-image.tar
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish_image_preview:
|
||||
name: Publish preview cloud image
|
||||
needs: [plan_preview, image_preview]
|
||||
if: github.ref == 'refs/heads/master' && needs.plan_preview.outputs.image == 'true' && needs.image_preview.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# This existing environment has an external master-only branch policy.
|
||||
environment: npm-canary
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: preview-image
|
||||
path: preview-image
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Verify image identity and publish without executing image code
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_ref }}
|
||||
run: node scripts/preview-artifacts.mjs publish-image preview-image/preview-image.tar "$SOURCE_SHA"
|
||||
|
||||
result_preview:
|
||||
name: Verify preview artifacts
|
||||
needs: [plan_preview, image_preview, publish_image_preview, package_preview, publish_preview]
|
||||
if: >-
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Confirm exact artifacts are visible
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_ref }}
|
||||
REQUEST_ID: ${{ inputs.request_id }}
|
||||
PREVIEW_MIGRATOR: ${{ inputs.preview_migrator }}
|
||||
run: node scripts/preview-artifacts.mjs result "$SOURCE_SHA" "$REQUEST_ID"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: stack-deploy-result
|
||||
overwrite: true
|
||||
path: stack-deploy-result/result.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
verify_canary:
|
||||
if: github.event_name == 'push'
|
||||
uses: ./.github/workflows/release-verify.yml
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
name: Reuse exact-source verification
|
||||
if: github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Require successful source checks for this exact master push
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
run: node scripts/cloud-source-verification.mjs "$SOURCE_SHA"
|
||||
|
||||
publish_canary:
|
||||
if: github.event_name == 'push'
|
||||
|
|
@ -81,6 +333,8 @@ jobs:
|
|||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
# For the explicit docker.yml dispatch below.
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
@ -139,6 +393,29 @@ jobs:
|
|||
git push origin "refs/tags/${tag}"
|
||||
echo "version=${tag#canary/v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Canary images previously relied on the master-push docker.yml run,
|
||||
# whose single pending concurrency slot gets superseded by every
|
||||
# newer push — on a busy day no canary image publishes at all (five
|
||||
# consecutive canaries shipped npm packages with no cloud image on
|
||||
# 2026-09-06, starving downstream managed deploys for ~18 hours).
|
||||
# Tag pushes made with GITHUB_TOKEN do not fire docker.yml's
|
||||
# triggers, so dispatch the image build at the canary tag
|
||||
# explicitly, exactly like the nightly and beta lanes: the run keys
|
||||
# its concurrency off the tag ref, so no master push can supersede
|
||||
# it, and docker.yml's `type=sha` mapping publishes the
|
||||
# sha-<short> and sha-<short>-cloud images either way.
|
||||
- name: Build Docker images for the canary tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{
|
||||
echo "## Canary published"
|
||||
echo ""
|
||||
echo "- Published canary: \`${{ steps.canary_tag.outputs.version }}\`"
|
||||
echo "- Docker build dispatched at \`canary/v${{ steps.canary_tag.outputs.version }}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
gh workflow run docker.yml --ref "refs/tags/canary/v${{ steps.canary_tag.outputs.version }}" --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# The package is already public when this gate runs. A red result leaves the
|
||||
# immutable canary in npm, but makes the release workflow visibly fail before
|
||||
# anyone mistakes an installable package for an onboardable one.
|
||||
|
|
|
|||
|
|
@ -12,14 +12,16 @@ 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:
|
||||
chaos_and_recovery:
|
||||
name: Restart, replay, trace, and recovery faults
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 40 || 45 }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
|
@ -41,7 +43,7 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build eval and Runner contracts
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ on:
|
|||
type: boolean
|
||||
default: true
|
||||
group:
|
||||
description: "Comma-separated groups (AND semantics: legacy,native,local,daytona,core,breadth)"
|
||||
description: "Comma-separated groups (AND semantics: legacy,native,local,daytona,warm,core,breadth)"
|
||||
type: string
|
||||
required: false
|
||||
suite:
|
||||
|
|
@ -42,9 +42,10 @@ permissions:
|
|||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: runner-full-stack-e2e-${{ inputs.target_branch || github.event.repository.default_branch }}
|
||||
group: runner-full-stack-e2e-${{ github.event_name == 'workflow_dispatch' && inputs.target_branch != '' && inputs.target_branch != github.event.repository.default_branch && format('development-{0}', inputs.target_branch) || format('protected-{0}', github.run_id) }}
|
||||
# Development branch campaigns supersede older runs for the same target.
|
||||
# Preserve every default-branch campaign for its paid audit trail.
|
||||
# Give protected/default-branch campaigns unique groups because GitHub also
|
||||
# replaces pending runs when cancel-in-progress is false.
|
||||
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.target_branch != '' && inputs.target_branch != github.event.repository.default_branch }}
|
||||
|
||||
jobs:
|
||||
|
|
@ -59,7 +60,9 @@ jobs:
|
|||
test_runner: ${{ steps.runner.outputs.runner }}
|
||||
max_parallel_default: ${{ steps.runner.outputs.max_parallel_default }}
|
||||
max_parallel_limit: ${{ steps.runner.outputs.max_parallel_limit }}
|
||||
playwright_channel: ${{ steps.runner.outputs.playwright_channel }}
|
||||
target_sha: ${{ steps.target.outputs.sha }}
|
||||
target_ref: ${{ steps.target.outputs.ref }}
|
||||
steps:
|
||||
- name: Require default branch and allowlisted numeric actor IDs
|
||||
env:
|
||||
|
|
@ -113,6 +116,7 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
echo "sha=$target_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=refs/heads/$TARGET_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved the requested repository branch to $target_sha."
|
||||
|
||||
- name: Select paid test runner
|
||||
|
|
@ -129,6 +133,7 @@ jobs:
|
|||
echo "runner=$aws_runner"
|
||||
echo "max_parallel_default=100"
|
||||
echo "max_parallel_limit=100"
|
||||
echo "playwright_channel=chrome"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo '::notice title=Paid runner routing::Using an ephemeral RunsOn Fleet runner'
|
||||
else
|
||||
|
|
@ -136,8 +141,9 @@ jobs:
|
|||
echo "runner=$github_runner"
|
||||
echo "max_parallel_default=32"
|
||||
echo "max_parallel_limit=57"
|
||||
echo "playwright_channel="
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo '::notice title=Paid runner routing::RUNNER_E2E_AWS_ENABLED is not true; using the existing paid runner'
|
||||
echo '::notice title=Paid runner routing::RUNNER_E2E_AWS_ENABLED is not true; using the proven GitHub-hosted runner'
|
||||
fi
|
||||
|
||||
target_lock:
|
||||
|
|
@ -156,14 +162,18 @@ jobs:
|
|||
ref: ${{ needs.authorize.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Resolve target lockfile without lifecycle scripts
|
||||
id: lock
|
||||
run: |
|
||||
|
|
@ -198,6 +208,9 @@ jobs:
|
|||
outputs:
|
||||
matrix: ${{ steps.catalog.outputs.matrix }}
|
||||
needs_daytona: ${{ steps.catalog.outputs.needs_daytona }}
|
||||
needs_runner_typescript: ${{ steps.catalog.outputs.needs_runner_typescript }}
|
||||
needs_native_binaries: ${{ steps.catalog.outputs.needs_native_binaries }}
|
||||
needs_remote_provider_pack: ${{ steps.catalog.outputs.needs_remote_provider_pack }}
|
||||
execution_ids: ${{ steps.catalog.outputs.execution_ids }}
|
||||
max_parallel: ${{ steps.catalog.outputs.max_parallel }}
|
||||
daytona_image_content_id: ${{ steps.daytona_image_content.outputs.content_id }}
|
||||
|
|
@ -227,7 +240,15 @@ jobs:
|
|||
cp "$lock" pnpm-lock.yaml
|
||||
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -301,9 +322,14 @@ jobs:
|
|||
args+=(--all)
|
||||
fi
|
||||
catalog_json="$(pnpm --silent test:e2e:runner -- "${args[@]}")"
|
||||
echo "matrix=$(jq -c '{include: .include}' <<< "$catalog_json")" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_daytona=$(jq -r '.needsDaytona' <<< "$catalog_json")" >> "$GITHUB_OUTPUT"
|
||||
echo "execution_ids=$(jq -c '.executionIds' <<< "$catalog_json")" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "matrix=$(jq -c '{include: .include}' <<< "$catalog_json")"
|
||||
echo "needs_daytona=$(jq -r '.needsDaytona' <<< "$catalog_json")"
|
||||
echo "needs_runner_typescript=$(jq -r '[.include[] | select((.profileId == "runner-opencode") or (.profileId | startswith("runner-acpx-")) or (.suiteId == "openrouter-model-breadth"))] | length > 0' <<< "$catalog_json")"
|
||||
echo "needs_native_binaries=$(jq -r '[.include[] | select((.profileId | startswith("runner-")) or (.suiteId == "openrouter-model-breadth"))] | length > 0' <<< "$catalog_json")"
|
||||
echo "needs_remote_provider_pack=$(jq -r '[.include[] | select((.environmentId == "daytona") and ((.profileId == "runner-opencode") or (.profileId | startswith("runner-acpx-"))))] | length > 0' <<< "$catalog_json")"
|
||||
echo "execution_ids=$(jq -c '.executionIds' <<< "$catalog_json")"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if ! [[ "$MAX_PARALLEL_LIMIT" =~ ^[1-9][0-9]*$ ]] || [ "$MAX_PARALLEL_LIMIT" -gt 100 ]; then
|
||||
echo "Runner selection emitted an invalid max-parallel limit." >&2
|
||||
exit 1
|
||||
|
|
@ -328,18 +354,21 @@ jobs:
|
|||
source_revision: ${{ steps.image.outputs.source_revision }}
|
||||
content_id: ${{ steps.image.outputs.content_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- if: needs.catalog.outputs.needs_daytona == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.authorize.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download resolved target lockfile
|
||||
if: needs.catalog.outputs.needs_daytona == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
artifact-ids: ${{ needs.target_lock.outputs.artifact_id }}
|
||||
path: ${{ runner.temp }}/runner-e2e-target-lock
|
||||
|
||||
- name: Restore resolved target lockfile
|
||||
if: needs.catalog.outputs.needs_daytona == 'true'
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
EXPECTED_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }}
|
||||
|
|
@ -380,13 +409,18 @@ jobs:
|
|||
NEEDS_DAYTONA: ${{ needs.catalog.outputs.needs_daytona }}
|
||||
IMAGE_CONTENT_ID: ${{ needs.catalog.outputs.daytona_image_content_id }}
|
||||
IMAGE_TAG: ghcr.io/paperclipai/paperclip-daytona-runner:e2e-content-${{ needs.catalog.outputs.daytona_image_content_id }}
|
||||
IMAGE_CACHE: ghcr.io/paperclipai/paperclip-daytona-runner:e2e-buildcache-amd64
|
||||
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
TARGET_REF: ${{ needs.authorize.outputs.target_ref }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$NEEDS_DAYTONA" != true ]; then
|
||||
echo "image=" >> "$GITHUB_OUTPUT"
|
||||
echo "source_revision=" >> "$GITHUB_OUTPUT"
|
||||
echo "content_id=" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "image="
|
||||
echo "source_revision="
|
||||
echo "content_id="
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
[[ "$IMAGE_CONTENT_ID" =~ ^[0-9a-f]{64}$ ]]
|
||||
|
|
@ -394,12 +428,24 @@ jobs:
|
|||
if docker buildx imagetools inspect "$IMAGE_TAG" >/dev/null 2>&1; then
|
||||
digest="$(docker buildx imagetools inspect "$IMAGE_TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
else
|
||||
cache_args=(
|
||||
--cache-from "type=registry,ref=${IMAGE_CACHE}"
|
||||
)
|
||||
if [ "$TARGET_REF" = "refs/heads/$DEFAULT_BRANCH" ]; then
|
||||
cache_args+=(
|
||||
--cache-to "type=registry,ref=${IMAGE_CACHE},mode=max"
|
||||
)
|
||||
echo '::notice title=Daytona image cache::Publishing cache from the trusted default-branch target'
|
||||
else
|
||||
echo '::notice title=Daytona image cache::Using the default-branch cache without publishing development-branch layers'
|
||||
fi
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--build-arg "PAPERCLIP_RUNNER_CONTENT_ID=${IMAGE_CONTENT_ID}" \
|
||||
--build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=${TARGET_SHA}" \
|
||||
--file docker/daytona-runner/Dockerfile \
|
||||
--tag "$IMAGE_TAG" \
|
||||
"${cache_args[@]}" \
|
||||
--push \
|
||||
.
|
||||
digest="$(docker buildx imagetools inspect "$IMAGE_TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
|
|
@ -410,39 +456,304 @@ jobs:
|
|||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
"$IMAGE_TAG@$digest" >/dev/null
|
||||
immutable="${IMAGE_TAG%:*}@$digest"
|
||||
# The Daytona base image is large. The build cache plus a second full
|
||||
# anonymous pull can exhaust a standard GitHub-hosted runner before
|
||||
# Docker creates the tiny metadata-probe container. The pushed digest
|
||||
# is already immutable, so release the local builder/cache first.
|
||||
docker buildx prune --all --force >/dev/null
|
||||
docker system prune --all --force >/dev/null
|
||||
anonymous_config="$(mktemp -d)"
|
||||
docker --config "$anonymous_config" pull "$immutable"
|
||||
# The Dockerfile's final two RUN steps execute the runner metadata,
|
||||
# transport-mode, provider-pack JSON, and pinned ACP binary checks as
|
||||
# root and as the unprivileged Daytona user. Starting another
|
||||
# container after this full pull can exhaust the hosted runner's thin
|
||||
# writable layer even after pruning, so assert the published image
|
||||
# configuration here without creating a redundant container.
|
||||
image_config="$(docker image inspect "$immutable" \
|
||||
--format '{{json .}}')"
|
||||
published_content_id="$(jq -r '.Config.Labels["io.paperclip.runner.content-id"] // empty' <<< "$image_config")"
|
||||
source_revision="$(jq -r '.Config.Labels["org.opencontainers.image.revision"] // empty' <<< "$image_config")"
|
||||
# root and as the unprivileged Daytona user. Buildx reads the signed
|
||||
# digest's OCI config directly from GHCR, so verification does not
|
||||
# download the image's large filesystem layers. Logging out first
|
||||
# preserves the proof that Daytona can retrieve this public image
|
||||
# without the workflow's package credentials.
|
||||
docker logout ghcr.io >/dev/null
|
||||
image_config="$(docker buildx imagetools inspect "$immutable" \
|
||||
--format '{{json .Image}}')"
|
||||
published_content_id="$(jq -r '.config.Labels["io.paperclip.runner.content-id"] // empty' <<< "$image_config")"
|
||||
source_revision="$(jq -r '.config.Labels["org.opencontainers.image.revision"] // empty' <<< "$image_config")"
|
||||
test "$published_content_id" = "$IMAGE_CONTENT_ID"
|
||||
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]]
|
||||
jq -e \
|
||||
'.Architecture == "amd64" and
|
||||
.Os == "linux" and
|
||||
.Config.User == "daytona" and
|
||||
(.Config.Env | any(startswith("PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=")))' \
|
||||
'.architecture == "amd64" and
|
||||
.os == "linux" and
|
||||
.config.User == "daytona" and
|
||||
(.config.Env | any(startswith("PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=")))' \
|
||||
<<< "$image_config" >/dev/null
|
||||
echo "image=$immutable" >> "$GITHUB_OUTPUT"
|
||||
echo "source_revision=$source_revision" >> "$GITHUB_OUTPUT"
|
||||
echo "content_id=$published_content_id" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "image=$immutable"
|
||||
echo "source_revision=$source_revision"
|
||||
echo "content_id=$published_content_id"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
build_runner_artifacts:
|
||||
name: Build reusable runner campaign artifacts
|
||||
needs: [authorize, target_lock, catalog]
|
||||
if: github.event_name != 'schedule' || vars.RUNNER_FULL_STACK_E2E_NIGHTLY_ENABLED == 'true'
|
||||
# Compile native binaries on the same reviewed image used to execute them,
|
||||
# avoiding libc/architecture drift between GitHub-hosted and AWS lanes.
|
||||
runs-on: ${{ needs.authorize.outputs.test_runner }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
build_artifact_name: ${{ steps.build_artifact_name.outputs.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.authorize.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download resolved target lockfile
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
artifact-ids: ${{ needs.target_lock.outputs.artifact_id }}
|
||||
path: ${{ runner.temp }}/runner-e2e-target-lock
|
||||
|
||||
- name: Restore resolved target lockfile
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
EXPECTED_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$TARGET_SHA"
|
||||
lock="$RUNNER_TEMP/runner-e2e-target-lock/pnpm-lock.yaml"
|
||||
test -f "$lock"
|
||||
test "$(find "$(dirname "$lock")" -type f | wc -l | tr -d ' ')" = 1
|
||||
test "$(sha256sum "$lock" | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
cp "$lock" pnpm-lock.yaml
|
||||
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# build:typescript also builds the eval-kernel dependency, so the two
|
||||
# TypeScript trees are compiled at most once in this campaign.
|
||||
- name: Build shared TypeScript and native runner outputs
|
||||
env:
|
||||
NEEDS_RUNNER_TYPESCRIPT: ${{ needs.catalog.outputs.needs_runner_typescript }}
|
||||
NEEDS_NATIVE_BINARIES: ${{ needs.catalog.outputs.needs_native_binaries }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$NEEDS_RUNNER_TYPESCRIPT" = true ]; then
|
||||
pnpm --filter @paperclipai/paperclip-runner build:typescript
|
||||
else
|
||||
pnpm --filter @paperclipai/paperclip-eval-kernel build
|
||||
fi
|
||||
if [ "$NEEDS_NATIVE_BINARIES" = true ]; then
|
||||
pnpm --filter @paperclipai/paperclip-runner build:runner-binaries
|
||||
fi
|
||||
|
||||
- name: Package immutable campaign outputs
|
||||
env:
|
||||
NEEDS_RUNNER_TYPESCRIPT: ${{ needs.catalog.outputs.needs_runner_typescript }}
|
||||
NEEDS_NATIVE_BINARIES: ${{ needs.catalog.outputs.needs_native_binaries }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
binary_root="packages/paperclip-runner/runner/target/debug"
|
||||
binaries=(
|
||||
conformance-tracer
|
||||
paperclip-runnerd
|
||||
fake-harness
|
||||
fake-codex-app-server
|
||||
fake-acpx-sidecar
|
||||
)
|
||||
archive_paths=(
|
||||
packages/paperclip-eval-kernel/dist
|
||||
)
|
||||
if [ "$NEEDS_RUNNER_TYPESCRIPT" = true ]; then
|
||||
test -d packages/paperclip-runner/dist
|
||||
archive_paths+=(packages/paperclip-runner/dist)
|
||||
fi
|
||||
if [ "$NEEDS_NATIVE_BINARIES" = true ]; then
|
||||
for binary in "${binaries[@]}"; do
|
||||
test -x "$binary_root/$binary"
|
||||
archive_paths+=("$binary_root/$binary")
|
||||
done
|
||||
fi
|
||||
tar --create --gzip \
|
||||
--file runner-e2e-build-bundle.tar.gz \
|
||||
"${archive_paths[@]}"
|
||||
sha256sum runner-e2e-build-bundle.tar.gz > runner-e2e-build-bundle.tar.gz.sha256
|
||||
|
||||
- name: Name immutable shared campaign outputs
|
||||
id: build_artifact_name
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
run: echo "name=runner-e2e-build-${TARGET_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload immutable shared campaign outputs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ steps.build_artifact_name.outputs.name }}
|
||||
path: |
|
||||
runner-e2e-build-bundle.tar.gz
|
||||
runner-e2e-build-bundle.tar.gz.sha256
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
build_remote_provider_pack:
|
||||
name: Build reusable remote provider pack
|
||||
needs:
|
||||
[authorize, target_lock, catalog, daytona_image, build_runner_artifacts]
|
||||
if: github.event_name != 'schedule' || vars.RUNNER_FULL_STACK_E2E_NIGHTLY_ENABLED == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
provider_pack_artifact_name: ${{ steps.provider_pack_artifact_name.outputs.name }}
|
||||
steps:
|
||||
- name: No remote provider pack needed
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack != 'true'
|
||||
run: echo "Selected cells do not require a remote provider pack."
|
||||
|
||||
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.authorize.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download resolved target lockfile
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
artifact-ids: ${{ needs.target_lock.outputs.artifact_id }}
|
||||
path: ${{ runner.temp }}/runner-e2e-target-lock
|
||||
|
||||
- name: Restore resolved target lockfile
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
EXPECTED_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$TARGET_SHA"
|
||||
lock="$RUNNER_TEMP/runner-e2e-target-lock/pnpm-lock.yaml"
|
||||
test -f "$lock"
|
||||
test "$(find "$(dirname "$lock")" -type f | wc -l | tr -d ' ')" = 1
|
||||
test "$(sha256sum "$lock" | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
cp "$lock" pnpm-lock.yaml
|
||||
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
|
||||
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Materialize verified pinned OpenCode executable
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
run: node packages/paperclip-runner/scripts/materialize-opencode-binary.mjs
|
||||
|
||||
- name: Download immutable shared campaign outputs
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ needs.build_runner_artifacts.outputs.build_artifact_name }}
|
||||
path: runner-e2e-build
|
||||
|
||||
- name: Verify and restore shared TypeScript outputs
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(
|
||||
cd runner-e2e-build
|
||||
sha256sum --check runner-e2e-build-bundle.tar.gz.sha256
|
||||
)
|
||||
tar --extract --gzip \
|
||||
--file runner-e2e-build/runner-e2e-build-bundle.tar.gz \
|
||||
--directory "$GITHUB_WORKSPACE"
|
||||
test -d packages/paperclip-eval-kernel/dist
|
||||
test -d packages/paperclip-runner/dist
|
||||
|
||||
- name: Assemble native remote provider pack
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
env:
|
||||
# A reused image can have an older source revision with the same
|
||||
# content ID. Matching that revision lets remote execution reuse the
|
||||
# verified pack already installed in the immutable image.
|
||||
PAPERCLIP_RUNNER_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }}
|
||||
run: node packages/paperclip-runner/scripts/build-provider-pack.mjs packages/paperclip-runner/provider-pack
|
||||
|
||||
- name: Package verified remote provider pack
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
env:
|
||||
IMAGE_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f packages/paperclip-runner/provider-pack/provider-pack.json
|
||||
jq -e \
|
||||
--arg revision "$IMAGE_SOURCE_REVISION" \
|
||||
'.schema == "paperclip-runner/remote-provider-pack/v1" and
|
||||
.payload.runnerSourceRevision == $revision and
|
||||
(.digest | test("^sha256:[0-9a-f]{64}$"))' \
|
||||
packages/paperclip-runner/provider-pack/provider-pack.json >/dev/null
|
||||
tar --create --gzip \
|
||||
--file runner-e2e-provider-pack.tar.gz \
|
||||
packages/paperclip-runner/provider-pack
|
||||
sha256sum runner-e2e-provider-pack.tar.gz > runner-e2e-provider-pack.tar.gz.sha256
|
||||
|
||||
- name: Name immutable remote provider pack
|
||||
id: provider_pack_artifact_name
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
run: echo "name=runner-e2e-provider-pack-${TARGET_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload immutable remote provider pack
|
||||
if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ steps.provider_pack_artifact_name.outputs.name }}
|
||||
path: |
|
||||
runner-e2e-provider-pack.tar.gz
|
||||
runner-e2e-provider-pack.tar.gz.sha256
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
test:
|
||||
name: ${{ matrix.executionId }}
|
||||
needs: [authorize, target_lock, catalog, daytona_image]
|
||||
needs:
|
||||
[
|
||||
authorize,
|
||||
target_lock,
|
||||
catalog,
|
||||
daytona_image,
|
||||
build_runner_artifacts,
|
||||
build_remote_provider_pack,
|
||||
]
|
||||
# The authorize job selects only one of two literal, reviewed runner labels;
|
||||
# no dispatch input or repository variable can inject an arbitrary label.
|
||||
runs-on: ${{ needs.authorize.outputs.test_runner }}
|
||||
|
|
@ -497,45 +808,199 @@ jobs:
|
|||
cp "$lock" pnpm-lock.yaml
|
||||
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# This job receives provider credentials only in the final paid-test
|
||||
# step. Keep target-selected dependency lifecycle code from running in
|
||||
# the protected environment during setup.
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build runner TypeScript prerequisites
|
||||
run: pnpm --filter @paperclipai/paperclip-eval-kernel build
|
||||
# Sandbox-provider plugins are intentionally excluded from the root
|
||||
# workspace. The ordinary root postinstall links the in-repo plugin SDK,
|
||||
# but that lifecycle hook is deliberately disabled above. Prepare the
|
||||
# one host plugin needed by Daytona explicitly, before this job receives
|
||||
# provider credentials, and keep dependency lifecycle scripts disabled.
|
||||
- name: Prepare bundled Daytona plugin without dependency lifecycle scripts
|
||||
if: matrix.environmentId == 'daytona'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
daytona_root="packages/plugins/sandbox-providers/daytona"
|
||||
sdk_root="packages/plugins/sdk"
|
||||
test -d "$daytona_root"
|
||||
test -d "$sdk_root"
|
||||
test ! -L "$daytona_root"
|
||||
test ! -L "$sdk_root"
|
||||
test -f "$daytona_root/pnpm-lock.yaml"
|
||||
test "$(jq -r .name "$daytona_root/package.json")" = "@paperclipai/plugin-daytona"
|
||||
test "$(jq -r .name "$sdk_root/package.json")" = "@paperclipai/plugin-sdk"
|
||||
(
|
||||
cd "$daytona_root"
|
||||
pnpm install --ignore-workspace --frozen-lockfile --ignore-scripts
|
||||
)
|
||||
node scripts/link-plugin-dev-sdk.mjs
|
||||
test "$(realpath "$daytona_root/node_modules/@paperclipai/plugin-sdk")" = "$(realpath "$sdk_root")"
|
||||
pnpm --dir "$daytona_root" build
|
||||
test -f "$daytona_root/dist/manifest.js"
|
||||
test -f "$daytona_root/dist/worker.js"
|
||||
test -e "$daytona_root/node_modules/@daytonaio/sdk"
|
||||
|
||||
- name: Build local JS-backed provider artifacts
|
||||
if: matrix.environmentId == 'local' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-'))
|
||||
run: pnpm --filter @paperclipai/paperclip-runner build:typescript
|
||||
- name: Materialize verified pinned OpenCode executable
|
||||
if: matrix.environmentId == 'local' && (matrix.profileId == 'legacy-opencode' || matrix.profileId == 'runner-opencode' || matrix.suiteId == 'openrouter-model-breadth')
|
||||
run: node packages/paperclip-runner/scripts/materialize-opencode-binary.mjs
|
||||
|
||||
- name: Build native remote provider pack
|
||||
- name: Download immutable campaign outputs
|
||||
if: startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ needs.build_runner_artifacts.outputs.build_artifact_name }}
|
||||
path: runner-e2e-build
|
||||
|
||||
- name: Download immutable remote provider pack
|
||||
if: matrix.environmentId == 'daytona' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-'))
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ needs.build_remote_provider_pack.outputs.provider_pack_artifact_name }}
|
||||
path: runner-e2e-provider-pack
|
||||
|
||||
- name: Verify and restore campaign outputs
|
||||
if: startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth'
|
||||
env:
|
||||
NEEDS_RUNNER_TYPESCRIPT: ${{ matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-') || matrix.suiteId == 'openrouter-model-breadth' }}
|
||||
NEEDS_NATIVE_BINARY: ${{ startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(
|
||||
cd runner-e2e-build
|
||||
sha256sum --check runner-e2e-build-bundle.tar.gz.sha256
|
||||
)
|
||||
tar --extract --gzip \
|
||||
--file runner-e2e-build/runner-e2e-build-bundle.tar.gz \
|
||||
--directory "$GITHUB_WORKSPACE"
|
||||
test -d packages/paperclip-eval-kernel/dist
|
||||
if [ "$NEEDS_RUNNER_TYPESCRIPT" = true ]; then
|
||||
test -d packages/paperclip-runner/dist
|
||||
fi
|
||||
if [ "$NEEDS_NATIVE_BINARY" = true ]; then
|
||||
test -x packages/paperclip-runner/runner/target/debug/paperclip-runnerd
|
||||
fi
|
||||
|
||||
- name: Verify and restore remote provider pack
|
||||
if: matrix.environmentId == 'daytona' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-'))
|
||||
env:
|
||||
# Reused images retain the source revision that was embedded in their
|
||||
# provider pack. Matching it here lets the server reuse that exact
|
||||
# preinstalled pack instead of uploading a duplicate to the lease.
|
||||
PAPERCLIP_RUNNER_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }}
|
||||
run: pnpm --filter @paperclipai/paperclip-runner build:provider-pack
|
||||
IMAGE_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(
|
||||
cd runner-e2e-provider-pack
|
||||
sha256sum --check runner-e2e-provider-pack.tar.gz.sha256
|
||||
)
|
||||
tar --extract --gzip \
|
||||
--file runner-e2e-provider-pack/runner-e2e-provider-pack.tar.gz \
|
||||
--directory "$GITHUB_WORKSPACE"
|
||||
jq -e \
|
||||
--arg revision "$IMAGE_SOURCE_REVISION" \
|
||||
'.schema == "paperclip-runner/remote-provider-pack/v1" and
|
||||
.payload.runnerSourceRevision == $revision and
|
||||
(.digest | test("^sha256:[0-9a-f]{64}$"))' \
|
||||
packages/paperclip-runner/provider-pack/provider-pack.json >/dev/null
|
||||
|
||||
- name: Qualify local provider Node interpreter
|
||||
if: matrix.environmentId == 'local' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-') || matrix.suiteId == 'openrouter-model-breadth')
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const mode = fs.statSync(process.execPath).mode & 0o777;
|
||||
fs.chmodSync(process.execPath, mode & ~0o022);
|
||||
if ((fs.statSync(process.execPath).mode & 0o022) !== 0) {
|
||||
throw new Error("provider Node interpreter remains group- or world-writable");
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Install pinned legacy Claude CLI
|
||||
if: matrix.profileId == 'legacy-claude'
|
||||
run: npm install --global --omit=dev @anthropic-ai/claude-code@2.1.19
|
||||
|
||||
- name: Build native runner binaries
|
||||
if: startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth'
|
||||
run: pnpm --filter @paperclipai/paperclip-runner build:runner-binaries
|
||||
- name: Qualify preinstalled Chrome
|
||||
if: needs.authorize.outputs.playwright_channel == 'chrome'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chrome_path="$(command -v google-chrome)"
|
||||
test -x "$chrome_path"
|
||||
google-chrome --version
|
||||
|
||||
- name: Install Chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Install Playwright FFmpeg on AWS runner
|
||||
if: needs.authorize.outputs.playwright_channel == 'chrome'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if pnpm exec playwright install ffmpeg; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Playwright FFmpeg installation failed after $attempt attempts." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
|
||||
- name: Install Chromium headless shell on GitHub-hosted fallback
|
||||
if: needs.authorize.outputs.playwright_channel != 'chrome'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if pnpm exec playwright install --with-deps --only-shell chromium; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Chromium headless shell installation failed after $attempt attempts." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
|
||||
# This definition executes only from the authorized default-branch workflow.
|
||||
# Provision host policy before credentials reach target-controlled tests.
|
||||
- name: Provision Codex sandbox on the disposable trusted runner
|
||||
if: matrix.environmentId == 'local' && matrix.profileId == 'runner-codex'
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
if (process.platform !== "linux") process.exit(0);
|
||||
let restricted = "0";
|
||||
try { restricted = readFileSync("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", "utf8").trim(); } catch {}
|
||||
if (restricted !== "1") process.exit(0);
|
||||
const root = realpathSync(process.env.GITHUB_WORKSPACE);
|
||||
const runnerRequire = createRequire(path.join(root, "packages/paperclip-runner/package.json"));
|
||||
const acpRequire = createRequire(runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"));
|
||||
const codexRequire = createRequire(acpRequire.resolve("@openai/codex/package.json"));
|
||||
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
|
||||
if (!arch) throw new Error("Unsupported Codex CI architecture");
|
||||
const platformPackage = codexRequire.resolve(`@openai/codex-linux-${arch}/package.json`);
|
||||
const triple = arch === "x64" ? "x86_64-unknown-linux-musl" : "aarch64-unknown-linux-musl";
|
||||
const suffix = `/vendor/${triple}/bin/codex`;
|
||||
const binary = realpathSync(path.join(path.dirname(platformPackage), suffix));
|
||||
if (!binary.startsWith(root + "/node_modules/.pnpm/") || !binary.endsWith(suffix) || !/^[/A-Za-z0-9_.@+\-]+$/.test(binary)) {
|
||||
throw new Error("Codex executable is outside the resolved dependency tree");
|
||||
}
|
||||
const name = `paperclip-e2e-codex-${createHash("sha256").update(binary).digest("hex").slice(0,16)}`;
|
||||
const profilePath = path.join(process.env.RUNNER_TEMP, "paperclip-codex-userns.apparmor");
|
||||
writeFileSync(profilePath, `abi <abi/4.0>,\ninclude <tunables/global>\nprofile ${name} "${binary}" flags=(unconfined) {\n userns,\n}\n`, {mode:0o600, flag:"wx"});
|
||||
execFileSync("sudo", ["-n", "apparmor_parser", "-r", profilePath], {timeout:15000, stdio:"pipe"});
|
||||
NODE
|
||||
|
||||
- name: Run paid cell
|
||||
env:
|
||||
|
|
@ -546,6 +1011,9 @@ jobs:
|
|||
PAPERCLIP_E2E_DAYTONA_IMAGE: ${{ needs.daytona_image.outputs.image }}
|
||||
PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH: ${{ github.workspace }}/packages/paperclip-runner/provider-pack
|
||||
PAPERCLIP_E2E_CAMPAIGN_ID: gha-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.executionId }}
|
||||
PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
PAPERCLIP_RUNNER_E2E_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }}
|
||||
PAPERCLIP_PLAYWRIGHT_CHANNEL: ${{ needs.authorize.outputs.playwright_channel }}
|
||||
run: pnpm test:e2e:runner -- --id "${{ matrix.executionId }}"
|
||||
|
||||
- name: Upload access-controlled packaged cell evidence
|
||||
|
|
@ -559,13 +1027,14 @@ jobs:
|
|||
|
||||
report:
|
||||
name: Merge and enforce campaign result
|
||||
if: always() && needs.catalog.result == 'success'
|
||||
if: always() && !cancelled() && needs.catalog.result == 'success'
|
||||
needs: [authorize, catalog, daytona_image, test]
|
||||
outputs:
|
||||
history_source_ready: ${{ steps.history_source_ready.outputs.ready }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
|
@ -574,7 +1043,15 @@ jobs:
|
|||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
|
|
@ -585,22 +1062,52 @@ jobs:
|
|||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Resolve workflow job attempts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api --paginate --slurp \
|
||||
"repos/$REPOSITORY/actions/runs/$RUN_ID/jobs?filter=all&per_page=100" \
|
||||
> runner-e2e-job-pages.json
|
||||
for attempt in $(seq 1 "${{ github.run_attempt }}"); do
|
||||
gh api "repos/$REPOSITORY/actions/runs/$RUN_ID/attempts/$attempt" \
|
||||
--jq '{run_attempt, run_started_at}'
|
||||
done > runner-e2e-attempts.jsonl
|
||||
jq -s '.' runner-e2e-attempts.jsonl > runner-e2e-attempts.json
|
||||
jq --slurpfile attempts runner-e2e-attempts.json \
|
||||
'{jobs: [.[].jobs[]], attempts: $attempts[0]}' \
|
||||
runner-e2e-job-pages.json > runner-e2e-jobs.json
|
||||
|
||||
- name: Download cell evidence
|
||||
id: download_evidence
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: runner-e2e-${{ github.run_id }}-${{ github.run_attempt }}-*
|
||||
pattern: runner-e2e-${{ github.run_id }}-*-*
|
||||
path: downloaded-runner-e2e
|
||||
merge-multiple: true
|
||||
merge-multiple: false
|
||||
|
||||
- name: Retry cell evidence download after transport failure
|
||||
if: steps.download_evidence.outcome == 'failure'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: runner-e2e-${{ github.run_id }}-${{ github.run_attempt }}-*
|
||||
pattern: runner-e2e-${{ github.run_id }}-*-*
|
||||
path: downloaded-runner-e2e
|
||||
merge-multiple: true
|
||||
merge-multiple: false
|
||||
|
||||
- name: Select latest workflow attempt per cell
|
||||
if: always()
|
||||
env:
|
||||
PAPERCLIP_RUNNER_E2E_ARTIFACT_ROOT: ${{ github.workspace }}/downloaded-runner-e2e
|
||||
PAPERCLIP_RUNNER_E2E_SELECTED_ROOT: ${{ github.workspace }}/selected-runner-e2e
|
||||
PAPERCLIP_RUNNER_E2E_JOBS_JSON: ${{ github.workspace }}/runner-e2e-jobs.json
|
||||
PAPERCLIP_RUNNER_E2E_EXPECTED_IDS: ${{ needs.catalog.outputs.execution_ids }}
|
||||
PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
PAPERCLIP_RUNNER_E2E_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }}
|
||||
run: node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/select-rerun-artifacts.ts
|
||||
|
||||
- name: Collect blob reports
|
||||
run: |
|
||||
|
|
@ -612,7 +1119,7 @@ jobs:
|
|||
if [ ! -e "$target" ]; then
|
||||
cp "$report" "$target"
|
||||
fi
|
||||
done < <(find downloaded-runner-e2e -path '*/blob-report/*.zip' -print0)
|
||||
done < <(find selected-runner-e2e -path '*/blob-report/*.zip' -print0)
|
||||
|
||||
- name: Merge Playwright HTML and JUnit
|
||||
if: always()
|
||||
|
|
@ -623,10 +1130,14 @@ jobs:
|
|||
- name: Aggregate normalized campaign results
|
||||
if: always()
|
||||
env:
|
||||
PAPERCLIP_RUNNER_E2E_REPORT_ROOT: ${{ github.workspace }}/downloaded-runner-e2e
|
||||
PAPERCLIP_RUNNER_E2E_REPORT_ROOT: ${{ github.workspace }}/selected-runner-e2e
|
||||
PAPERCLIP_RUNNER_E2E_REPORT_OUT: ${{ github.workspace }}/runner-e2e-merged-report/normalized
|
||||
PAPERCLIP_RUNNER_E2E_EXPECTED_IDS: ${{ needs.catalog.outputs.execution_ids }}
|
||||
PAPERCLIP_E2E_CAMPAIGN_ID: gha-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
PAPERCLIP_RUNNER_E2E_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }}
|
||||
PAPERCLIP_RUNNER_E2E_HISTORY_PUBLIC_BASE_URL: ${{ vars.RUNNER_E2E_HISTORY_PUBLIC_BASE_URL }}
|
||||
PAPERCLIP_RUNNER_E2E_HISTORY_PREFIX: ${{ vars.RUNNER_E2E_HISTORY_PREFIX || 'runner-e2e' }}
|
||||
run: |
|
||||
set +e
|
||||
pnpm test:e2e:runner:report
|
||||
|
|
@ -644,25 +1155,26 @@ jobs:
|
|||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Verify history source report and private screenshot evidence
|
||||
- name: Verify normalized history source report
|
||||
id: history_source_ready
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dashboard_root="runner-e2e-merged-report/normalized"
|
||||
private_screenshot="$(find "$dashboard_root" -type f -name '*.png' -print -quit 2>/dev/null || true)"
|
||||
if [ -f "$dashboard_root/index.html" ] && [ -n "$private_screenshot" ]; then
|
||||
if [ -f "$dashboard_root/index.html" ] && [ -f "$dashboard_root/normalized-results.json" ]; then
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
publish_history:
|
||||
name: Publish pruned immutable history and landing site
|
||||
name: Publish S3 history and Pages bundle with declared screenshots
|
||||
needs: [authorize, catalog, report]
|
||||
if: always() && needs.catalog.result == 'success' && needs.report.outputs.history_source_ready == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
pages_artifact_name: ${{ steps.pages_artifact_name.outputs.name }}
|
||||
concurrency:
|
||||
group: runner-e2e-history-publish
|
||||
cancel-in-progress: false
|
||||
|
|
@ -678,16 +1190,23 @@ jobs:
|
|||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install publisher-only Chromium
|
||||
run: pnpm exec playwright install --with-deps --only-shell chromium
|
||||
|
||||
- name: Download access-controlled normalized campaign
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
|
|
@ -700,7 +1219,7 @@ jobs:
|
|||
role-to-assume: ${{ vars.RUNNER_E2E_HISTORY_AWS_ROLE_ARN }}
|
||||
aws-region: ${{ vars.RUNNER_E2E_HISTORY_AWS_REGION }}
|
||||
|
||||
- name: Prune private evidence and publish immutable campaign history
|
||||
- name: Publish trusted summary and declared screenshots to public bundles
|
||||
env:
|
||||
PAPERCLIP_RUNNER_E2E_REPORT_DIR: ${{ github.workspace }}/runner-e2e-merged-report/normalized
|
||||
RUNNER_E2E_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_HISTORY_S3_BUCKET }}
|
||||
|
|
@ -708,14 +1227,20 @@ jobs:
|
|||
RUNNER_E2E_HISTORY_PUBLIC_BASE_URL: ${{ vars.RUNNER_E2E_HISTORY_PUBLIC_BASE_URL }}
|
||||
run: pnpm test:e2e:runner:history:publish
|
||||
|
||||
- name: Package pruned structured dashboard for GitHub Pages
|
||||
- name: Resolve Pages artifact name
|
||||
id: pages_artifact_name
|
||||
if: vars.RUNNER_FULL_STACK_E2E_PUBLISH_PAGES == 'true'
|
||||
run: echo "name=github-pages-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Package pruned dashboard with declared screenshots for GitHub Pages
|
||||
if: vars.RUNNER_FULL_STACK_E2E_PUBLISH_PAGES == 'true'
|
||||
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
|
||||
with:
|
||||
path: runner-e2e-merged-report/normalized
|
||||
name: ${{ steps.pages_artifact_name.outputs.name }}
|
||||
path: runner-e2e-merged-report/pages
|
||||
|
||||
pages:
|
||||
name: Publish latest structured dashboard
|
||||
name: Publish latest dashboard with declared screenshots
|
||||
needs: [report, publish_history]
|
||||
if: always() && needs.report.outputs.history_source_ready == 'true' && needs.publish_history.result == 'success' && vars.RUNNER_FULL_STACK_E2E_PUBLISH_PAGES == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -729,3 +1254,7 @@ jobs:
|
|||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
with:
|
||||
# If only this failed job is rerun, GitHub retains the successful
|
||||
# publisher job's output from the earlier workflow attempt.
|
||||
artifact_name: ${{ needs.publish_history.outputs.pages_artifact_name }}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ on:
|
|||
schedule:
|
||||
- cron: "17 6 * * 0"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
candidate:
|
||||
description: "Comma-separated live candidate IDs (for example codex-luna)"
|
||||
type: string
|
||||
required: false
|
||||
case:
|
||||
description: "Comma-separated workflow case IDs"
|
||||
type: string
|
||||
required: false
|
||||
limit:
|
||||
description: "Maximum executions after candidate/case filtering"
|
||||
type: string
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: runner-live-evals-${{ github.ref }}
|
||||
|
|
@ -17,6 +30,8 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
eval_runner: ${{ steps.runner.outputs.runner }}
|
||||
steps:
|
||||
- name: Require default branch and allowlisted numeric actor IDs
|
||||
env:
|
||||
|
|
@ -50,11 +65,30 @@ jobs:
|
|||
fi
|
||||
done
|
||||
|
||||
- name: Select paid eval runner
|
||||
id: runner
|
||||
env:
|
||||
AWS_PAID_RUNNER_ENABLED: ${{ vars.RUNNER_E2E_AWS_ENABLED }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
github_runner='ubuntu-latest'
|
||||
aws_runner='runs-on/fleet=paperclip-public-pr-x64/env=public-ci'
|
||||
|
||||
if [ "$AWS_PAID_RUNNER_ENABLED" = true ]; then
|
||||
echo "runner=$aws_runner" >> "$GITHUB_OUTPUT"
|
||||
echo '::notice title=Paid eval routing::Using an ephemeral RunsOn Fleet runner'
|
||||
else
|
||||
echo "runner=$github_runner" >> "$GITHUB_OUTPUT"
|
||||
echo '::notice title=Paid eval routing::RUNNER_E2E_AWS_ENABLED is not true; using the proven GitHub-hosted runner'
|
||||
fi
|
||||
|
||||
live_matrix:
|
||||
name: Balanced provider/model matrix
|
||||
needs: authorize
|
||||
if: github.event_name != 'schedule' || vars.RUNNER_LIVE_EVALS_NIGHTLY_ENABLED == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# The authorize job selects one of two literal, reviewed runner labels;
|
||||
# dispatch inputs and repository variables cannot inject an arbitrary label.
|
||||
runs-on: ${{ needs.authorize.outputs.eval_runner }}
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -81,6 +115,26 @@ jobs:
|
|||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Generate private eval-repository token
|
||||
id: evals_token
|
||||
env:
|
||||
COMMITPERCLIP_KEY: ${{ secrets.COMMITPERCLIP_KEY }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
token="$(node .github/scripts/get-bot-token.mjs)"
|
||||
echo "::add-mask::$token"
|
||||
echo "value=$token" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout canonical Evalbook reporter
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
repository: paperclipai/paperclip-evals
|
||||
ref: 0c8dfea0ef71a73e909b59a5c0484554cbee199b
|
||||
path: .paperclip-evals
|
||||
token: ${{ steps.evals_token.outputs.value }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
|
|
@ -114,6 +168,10 @@ jobs:
|
|||
PAPERCLIP_EVAL_RUNNER_BUILD: ${{ github.sha }}
|
||||
PAPERCLIP_EVAL_MAX_CAMPAIGN_COST_USD: "12"
|
||||
PAPERCLIP_EVAL_SCHEDULE_SEED: runner-live-seven-week-v1
|
||||
PAPERCLIP_EVAL_CANDIDATE: ${{ inputs.candidate }}
|
||||
PAPERCLIP_EVAL_CASE: ${{ inputs.case }}
|
||||
PAPERCLIP_EVAL_LIMIT: ${{ inputs.limit }}
|
||||
PAPERCLIP_EVALBOOK_PROGRAM: ${{ github.workspace }}/.paperclip-evals/evals/paperclip-runner/tools/eval_program.py
|
||||
run: pnpm --filter @paperclipai/paperclip-runner report:runner-live-evals
|
||||
|
||||
- name: Publish job summary
|
||||
|
|
|
|||
|
|
@ -0,0 +1,720 @@
|
|||
name: Runner Direct Live Protocol Evals
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 9 * * 0"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_branch:
|
||||
description: "Branch in paperclipai/paperclip to evaluate; trusted orchestration still runs from master"
|
||||
type: string
|
||||
required: false
|
||||
evals_sha:
|
||||
description: "Exact 40-character paperclipai/paperclip-evals commit to execute"
|
||||
type: string
|
||||
required: false
|
||||
rosters:
|
||||
description: "Comma-separated live roster IDs/files, or all for the maintained enabled direct suite"
|
||||
type: string
|
||||
default: "all"
|
||||
required: false
|
||||
max_infrastructure_retries:
|
||||
description: "Automatic retries only for explicitly retryable infrastructure failures (0-3)"
|
||||
type: number
|
||||
default: 1
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: runner-protocol-live-evals-${{ github.event_name == 'workflow_dispatch' && inputs.target_branch != '' && inputs.target_branch != github.event.repository.default_branch && format('development-{0}', inputs.target_branch) || format('protected-{0}', github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.target_branch != '' && inputs.target_branch != github.event.repository.default_branch }}
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
name: Authorize paid direct eval campaign
|
||||
if: github.event_name != 'schedule' || vars.RUNNER_PROTOCOL_EVAL_NIGHTLY_ENABLED == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
test_runner: ${{ steps.runner.outputs.runner }}
|
||||
max_parallel_default: ${{ steps.runner.outputs.max_parallel_default }}
|
||||
max_parallel_limit: ${{ steps.runner.outputs.max_parallel_limit }}
|
||||
target_sha: ${{ steps.target.outputs.sha }}
|
||||
target_ref: ${{ steps.target.outputs.ref }}
|
||||
evals_sha: ${{ steps.evals.outputs.sha }}
|
||||
steps:
|
||||
- name: Require default branch and allowlisted numeric actor IDs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
ACTOR_ID: ${{ github.actor_id }}
|
||||
TRIGGERING_ACTOR: ${{ github.triggering_actor }}
|
||||
ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$REF" != "refs/heads/$DEFAULT_BRANCH" ]; then
|
||||
echo "Paid direct Runner eval campaigns may run only from the default branch." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e 'type == "array" and length > 0 and all(.[]; type == "number" and . > 0 and floor == .)' <<< "${ALLOWED_ACTOR_IDS:-}" >/dev/null; then
|
||||
echo "RUNNER_E2E_ALLOWED_ACTOR_IDS must be a non-empty JSON array of numeric GitHub user IDs." >&2
|
||||
exit 1
|
||||
fi
|
||||
triggering_actor_id="$(gh api "users/$TRIGGERING_ACTOR" --jq .id)"
|
||||
if [ "$triggering_actor_id" != "$ACTOR_ID" ] && [ "$TRIGGERING_ACTOR" = "$ACTOR" ]; then
|
||||
echo "GitHub actor identity contexts disagree; refusing the paid run." >&2
|
||||
exit 1
|
||||
fi
|
||||
for candidate in "$triggering_actor_id" "$ACTOR_ID"; do
|
||||
if ! jq -e --argjson candidate "$candidate" 'index($candidate) != null' <<< "$ALLOWED_ACTOR_IDS" >/dev/null; then
|
||||
echo "The initiating GitHub account is not authorized to run paid Runner eval campaigns." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Resolve requested Paperclip branch to an immutable commit
|
||||
id: target
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch || github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$TARGET_BRANCH" ] || [[ "$TARGET_BRANCH" == refs/* ]]; then
|
||||
echo "target_branch must name a branch in this repository without a refs/ prefix." >&2
|
||||
exit 1
|
||||
fi
|
||||
encoded_branch="$(jq -rn --arg branch "$TARGET_BRANCH" '$branch | @uri')"
|
||||
target_sha="$(gh api -X GET "repos/$REPOSITORY/branches/$encoded_branch" --jq .commit.sha)"
|
||||
[[ "$target_sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
echo "sha=$target_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=refs/heads/$TARGET_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate private eval-repository token
|
||||
id: evals_token
|
||||
env:
|
||||
COMMITPERCLIP_KEY: ${{ secrets.COMMITPERCLIP_KEY }}
|
||||
GH_REPO: paperclipai/paperclip-evals
|
||||
run: |
|
||||
set -euo pipefail
|
||||
token="$(node .github/scripts/get-bot-token.mjs)"
|
||||
echo "::add-mask::$token"
|
||||
echo "value=$token" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify the private eval program is pinned to an exact commit
|
||||
id: evals
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.evals_token.outputs.value }}
|
||||
EVALS_SHA: ${{ inputs.evals_sha || vars.RUNNER_PROTOCOL_EVALS_SHA }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! [[ "$EVALS_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "evals_sha (or RUNNER_PROTOCOL_EVALS_SHA for schedules) must be an exact 40-character commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
resolved="$(gh api -X GET "repos/paperclipai/paperclip-evals/commits/$EVALS_SHA" --jq .sha)"
|
||||
test "$resolved" = "$EVALS_SHA"
|
||||
echo "sha=$resolved" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate retry envelope
|
||||
env:
|
||||
RETRIES: ${{ github.event_name == 'schedule' && 1 || inputs.max_infrastructure_retries }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$RETRIES" =~ ^[0-3]$ ]]
|
||||
|
||||
- name: Select paid test runner
|
||||
id: runner
|
||||
env:
|
||||
AWS_PAID_RUNNER_ENABLED: ${{ vars.RUNNER_E2E_AWS_ENABLED }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$AWS_PAID_RUNNER_ENABLED" = true ]; then
|
||||
{
|
||||
echo 'runner=runs-on/fleet=paperclip-public-pr-x64/env=public-ci'
|
||||
echo 'max_parallel_default=100'
|
||||
echo 'max_parallel_limit=100'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
{
|
||||
echo 'runner=ubuntu-latest'
|
||||
echo 'max_parallel_default=32'
|
||||
echo 'max_parallel_limit=57'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
catalog:
|
||||
name: Pin and fan out the direct Evalbook roster
|
||||
needs: authorize
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
matrix_0: ${{ steps.catalog.outputs.matrix_0 }}
|
||||
matrix_1: ${{ steps.catalog.outputs.matrix_1 }}
|
||||
matrix_1_present: ${{ steps.catalog.outputs.matrix_1_present }}
|
||||
max_parallel_per_shard: ${{ steps.catalog.outputs.max_parallel_per_shard }}
|
||||
selected: ${{ steps.catalog.outputs.selected }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate private eval-repository token
|
||||
id: evals_token
|
||||
env:
|
||||
COMMITPERCLIP_KEY: ${{ secrets.COMMITPERCLIP_KEY }}
|
||||
GH_REPO: paperclipai/paperclip-evals
|
||||
run: |
|
||||
set -euo pipefail
|
||||
token="$(node .github/scripts/get-bot-token.mjs)"
|
||||
echo "::add-mask::$token"
|
||||
echo "value=$token" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
repository: paperclipai/paperclip-evals
|
||||
ref: ${{ needs.authorize.outputs.evals_sha }}
|
||||
path: .paperclip-evals
|
||||
token: ${{ steps.evals_token.outputs.value }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build the two bounded roster-plus-case matrices
|
||||
id: catalog
|
||||
env:
|
||||
PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
PAPERCLIP_PROTOCOL_EVALS_SHA: ${{ needs.authorize.outputs.evals_sha }}
|
||||
MAX_PARALLEL: ${{ vars.RUNNER_E2E_MAX_PARALLEL || needs.authorize.outputs.max_parallel_default }}
|
||||
MAX_PARALLEL_LIMIT: ${{ needs.authorize.outputs.max_parallel_limit }}
|
||||
ROSTERS: ${{ inputs.rosters || 'all' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! [[ "$MAX_PARALLEL" =~ ^[1-9][0-9]*$ ]] || [ "$MAX_PARALLEL" -lt 2 ] || [ "$MAX_PARALLEL" -gt "$MAX_PARALLEL_LIMIT" ]; then
|
||||
echo "RUNNER_E2E_MAX_PARALLEL must be an integer from 2 through $MAX_PARALLEL_LIMIT for the two-shard direct suite." >&2
|
||||
exit 1
|
||||
fi
|
||||
node packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs catalog \
|
||||
--evals-root .paperclip-evals \
|
||||
--rosters "$ROSTERS" \
|
||||
--campaign-id "gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
|
||||
--max-parallel "$MAX_PARALLEL" \
|
||||
--output runner-protocol-eval-catalog.json
|
||||
|
||||
- name: Require the chat-report renderer before paid execution
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .paperclip-evals/evals/paperclip-runner/tools/eval_program.py report --help | grep -q -- --public-viewer
|
||||
|
||||
- name: Upload immutable campaign catalog
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-eval-catalog-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-eval-catalog.json
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
build_runner:
|
||||
name: Build portable direct-eval runner once
|
||||
needs: [authorize, catalog]
|
||||
runs-on: ${{ needs.authorize.outputs.test_runner }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.authorize.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build runner CLI, daemon, and canonical attempt viewer
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm --filter @paperclipai/paperclip-runner build:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner build:runner-binaries
|
||||
pnpm --filter @paperclipai/paperclip-runner build:issue-thread
|
||||
# Older target refs must fail before paid cells, not publish an empty viewer.
|
||||
grep -q 'paperclip-eval-report' packages/paperclip-runner/dist-issue-thread/assets/*.js
|
||||
grep -q 'evalbook-site' packages/paperclip-runner/dist-issue-thread/assets/*.css
|
||||
|
||||
- name: Package a portable provider runtime
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/runner-protocol-build/package" "$RUNNER_TEMP/runner-protocol-build/portable"
|
||||
pnpm --dir packages/paperclip-runner pack \
|
||||
--pack-destination "$RUNNER_TEMP/runner-protocol-build/package"
|
||||
package="$(find "$RUNNER_TEMP/runner-protocol-build/package" -maxdepth 1 -type f -name '*.tgz' -print -quit)"
|
||||
test -f "$package"
|
||||
pnpm --filter @paperclipai/paperclip-runner deploy --prod \
|
||||
"$RUNNER_TEMP/runner-protocol-build/portable"
|
||||
cp "$package" "$RUNNER_TEMP/runner-protocol-build/paperclip-runner.tgz"
|
||||
cp packages/paperclip-runner/runner/target/debug/paperclip-runnerd "$RUNNER_TEMP/runner-protocol-build/paperclip-runnerd"
|
||||
cp -R packages/paperclip-runner/dist-issue-thread "$RUNNER_TEMP/runner-protocol-build/dist-issue-thread"
|
||||
test -f "$RUNNER_TEMP/runner-protocol-build/portable/dist/cli/eval-session.js"
|
||||
test -d "$RUNNER_TEMP/runner-protocol-build/portable/node_modules/.pnpm"
|
||||
test -x "$RUNNER_TEMP/runner-protocol-build/paperclip-runnerd"
|
||||
tar --create --gzip --file runner-protocol-build.tar.gz -C "$RUNNER_TEMP/runner-protocol-build" .
|
||||
sha256sum runner-protocol-build.tar.gz > runner-protocol-build.tar.gz.sha256
|
||||
|
||||
- name: Upload immutable portable runner
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-build-${{ needs.authorize.outputs.target_sha }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
runner-protocol-build.tar.gz
|
||||
runner-protocol-build.tar.gz.sha256
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload canonical viewer for publisher byte verification
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-viewer-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: packages/paperclip-runner/dist-issue-thread/
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
eval_shard_0:
|
||||
name: Direct eval ${{ matrix.rosterId }} / ${{ matrix.caseId }}
|
||||
needs: [authorize, catalog, build_runner]
|
||||
runs-on: ${{ needs.authorize.outputs.test_runner }}
|
||||
timeout-minutes: 18
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: runner-e2e-paid
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(needs.catalog.outputs.max_parallel_per_shard) }}
|
||||
matrix: ${{ fromJSON(needs.catalog.outputs.matrix_0) }}
|
||||
steps: &direct_eval_steps
|
||||
- name: Reauthorize paid execution before provider access
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REF: ${{ github.ref }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
ACTOR_ID: ${{ github.actor_id }}
|
||||
TRIGGERING_ACTOR: ${{ github.triggering_actor }}
|
||||
ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$REF" = "refs/heads/$DEFAULT_BRANCH"
|
||||
triggering_actor_id="$(gh api "users/$TRIGGERING_ACTOR" --jq .id)"
|
||||
test "$triggering_actor_id" = "$ACTOR_ID" || test "$TRIGGERING_ACTOR" != "$ACTOR"
|
||||
for candidate in "$triggering_actor_id" "$ACTOR_ID"; do
|
||||
jq -e --argjson candidate "$candidate" 'type == "array" and index($candidate) != null' <<< "$ALLOWED_ACTOR_IDS" >/dev/null
|
||||
done
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate private eval-repository token
|
||||
id: evals_token
|
||||
env:
|
||||
COMMITPERCLIP_KEY: ${{ secrets.COMMITPERCLIP_KEY }}
|
||||
GH_REPO: paperclipai/paperclip-evals
|
||||
run: |
|
||||
set -euo pipefail
|
||||
token="$(node .github/scripts/get-bot-token.mjs)"
|
||||
echo "::add-mask::$token"
|
||||
echo "value=$token" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
repository: paperclipai/paperclip-evals
|
||||
ref: ${{ needs.authorize.outputs.evals_sha }}
|
||||
path: .paperclip-evals
|
||||
token: ${{ steps.evals_token.outputs.value }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Download portable runner
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: runner-protocol-build-${{ needs.authorize.outputs.target_sha }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-build
|
||||
|
||||
- name: Verify and extract portable runner
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd runner-protocol-build
|
||||
sha256sum --check runner-protocol-build.tar.gz.sha256
|
||||
mkdir extracted
|
||||
tar --extract --gzip --file runner-protocol-build.tar.gz --directory extracted
|
||||
test -x extracted/paperclip-runnerd
|
||||
|
||||
- name: Prepare short-lived AgentCore web identity
|
||||
if: matrix.credentialName == 'AWS_AGENTCORE_OIDC'
|
||||
env:
|
||||
AGENTCORE_ROLE_ARN: ${{ vars.PAPERCLIP_AWS_AGENTCORE_EXECUTION_ROLE_ARN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$AGENTCORE_ROLE_ARN"
|
||||
token="$(curl --fail --silent --show-error \
|
||||
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
|
||||
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com" | jq -r .value)"
|
||||
test -n "$token"
|
||||
echo "::add-mask::$token"
|
||||
token_file="$RUNNER_TEMP/runner-protocol-agentcore-token"
|
||||
printf '%s' "$token" > "$token_file"
|
||||
chmod 600 "$token_file"
|
||||
{
|
||||
echo "AWS_WEB_IDENTITY_TOKEN_FILE=$token_file"
|
||||
echo "AWS_ROLE_ARN=$AGENTCORE_ROLE_ARN"
|
||||
echo "AWS_ROLE_SESSION_NAME=runner-protocol-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run one immutable direct protocol cell
|
||||
id: direct_eval
|
||||
env:
|
||||
CELL_ID: ${{ matrix.cellId }}
|
||||
ROSTER_FILE: ${{ matrix.rosterFile }}
|
||||
CASE_ID: ${{ matrix.caseId }}
|
||||
CREDENTIAL_NAME: ${{ matrix.credentialName }}
|
||||
PROVIDER: ${{ matrix.provider }}
|
||||
MAX_INFRASTRUCTURE_RETRIES: ${{ github.event_name == 'schedule' && 1 || inputs.max_infrastructure_retries }}
|
||||
OPENAI_API_KEY: ${{ matrix.credentialName == 'OPENAI_API_KEY' && secrets.OPENAI_API_KEY || '' }}
|
||||
ANTHROPIC_API_KEY: ${{ matrix.credentialName == 'ANTHROPIC_API_KEY' && secrets.ANTHROPIC_API_KEY || '' }}
|
||||
OPENROUTER_API_KEY: ${{ matrix.credentialName == 'OPENROUTER_API_KEY' && secrets.OPENROUTER_API_KEY || '' }}
|
||||
PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID }}
|
||||
PAPERCLIP_CLAUDE_MANAGED_AGENT_ID: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_AGENT_ID }}
|
||||
PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION }}
|
||||
PAPERCLIP_CLAUDE_MANAGED_ENVIRONMENT_ID: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_ENVIRONMENT_ID }}
|
||||
AWS_REGION: ${{ vars.PAPERCLIP_AWS_AGENTCORE_REGION }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.PAPERCLIP_AWS_AGENTCORE_REGION }}
|
||||
PAPERCLIP_AWS_AGENTCORE_PROFILE_ID: ${{ vars.PAPERCLIP_AWS_AGENTCORE_PROFILE_ID }}
|
||||
PAPERCLIP_AWS_AGENTCORE_ACCOUNT_ID: ${{ vars.PAPERCLIP_AWS_AGENTCORE_ACCOUNT_ID }}
|
||||
PAPERCLIP_AWS_AGENTCORE_HARNESS_ARN: ${{ vars.PAPERCLIP_AWS_AGENTCORE_HARNESS_ARN }}
|
||||
PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION: ${{ vars.PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION }}
|
||||
PAPERCLIP_AWS_AGENTCORE_ENDPOINT_ARN: ${{ vars.PAPERCLIP_AWS_AGENTCORE_ENDPOINT_ARN }}
|
||||
PAPERCLIP_AWS_AGENTCORE_ENDPOINT_QUALIFIER: ${{ vars.PAPERCLIP_AWS_AGENTCORE_ENDPOINT_QUALIFIER }}
|
||||
PAPERCLIP_AWS_AGENTCORE_RUNTIME_ARN: ${{ vars.PAPERCLIP_AWS_AGENTCORE_RUNTIME_ARN }}
|
||||
PAPERCLIP_AWS_AGENTCORE_MEMORY_ARN: ${{ vars.PAPERCLIP_AWS_AGENTCORE_MEMORY_ARN }}
|
||||
PAPERCLIP_AWS_AGENTCORE_MEMORY_ID: ${{ vars.PAPERCLIP_AWS_AGENTCORE_MEMORY_ID }}
|
||||
PAPERCLIP_AWS_AGENTCORE_INVOCATION_ROLE_ARN: ${{ vars.PAPERCLIP_AWS_AGENTCORE_INVOCATION_ROLE_ARN }}
|
||||
PAPERCLIP_AWS_AGENTCORE_CONTEXT_BUCKET: ${{ vars.PAPERCLIP_AWS_AGENTCORE_CONTEXT_BUCKET }}
|
||||
PAPERCLIP_AWS_AGENTCORE_CONTEXT_PREFIX: ${{ vars.PAPERCLIP_AWS_AGENTCORE_CONTEXT_PREFIX }}
|
||||
PAPERCLIP_AWS_AGENTCORE_CONTEXT_KMS_KEY_ARN: ${{ vars.PAPERCLIP_AWS_AGENTCORE_CONTEXT_KMS_KEY_ARN }}
|
||||
PAPERCLIP_AWS_AGENTCORE_QUALIFICATION_REVISION: ${{ vars.PAPERCLIP_AWS_AGENTCORE_QUALIFICATION_REVISION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p cell-output/runs
|
||||
if [ "$CREDENTIAL_NAME" != "AWS_AGENTCORE_OIDC" ]; then
|
||||
test -n "${!CREDENTIAL_NAME:-}"
|
||||
fi
|
||||
if [ "$PROVIDER" = "claude_managed" ]; then
|
||||
test -n "$PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID"
|
||||
test -n "$PAPERCLIP_CLAUDE_MANAGED_AGENT_ID"
|
||||
test -n "$PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION"
|
||||
test -n "$PAPERCLIP_CLAUDE_MANAGED_ENVIRONMENT_ID"
|
||||
fi
|
||||
set +e
|
||||
python3 .paperclip-evals/evals/paperclip-runner/tools/run_live_roster.py run \
|
||||
--roster ".paperclip-evals/evals/paperclip-runner/rosters/$ROSTER_FILE" \
|
||||
--case "$CASE_ID" \
|
||||
--runner-cli runner-protocol-build/extracted/portable/dist/cli/eval-session.js \
|
||||
--runner-package runner-protocol-build/extracted/paperclip-runner.tgz \
|
||||
--runnerd runner-protocol-build/extracted/paperclip-runnerd \
|
||||
--runs-root cell-output/runs \
|
||||
--max-infrastructure-retries "$MAX_INFRASTRUCTURE_RETRIES" \
|
||||
--run-id "gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${CELL_ID}"
|
||||
status=$?
|
||||
set -e
|
||||
CELL_EXIT_CODE="$status" node --input-type=module <<'NODE'
|
||||
import { writeFileSync } from "node:fs";
|
||||
writeFileSync("cell-output/cell.json", `${JSON.stringify({
|
||||
schema: "paperclip.runner-protocol-eval.cell/v1",
|
||||
cellId: process.env.CELL_ID,
|
||||
rosterFile: process.env.ROSTER_FILE,
|
||||
caseId: process.env.CASE_ID,
|
||||
exitCode: Number(process.env.CELL_EXIT_CODE),
|
||||
}, null, 2)}\n`, { mode: 0o600 });
|
||||
NODE
|
||||
exit "$status"
|
||||
|
||||
- name: Upload access-controlled cell attempt
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-eval-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.cellId }}
|
||||
path: cell-output/
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
eval_shard_1:
|
||||
name: Direct eval ${{ matrix.rosterId }} / ${{ matrix.caseId }}
|
||||
if: needs.catalog.outputs.matrix_1_present == 'true'
|
||||
needs: [authorize, catalog, build_runner]
|
||||
runs-on: ${{ needs.authorize.outputs.test_runner }}
|
||||
timeout-minutes: 18
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: runner-e2e-paid
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(needs.catalog.outputs.max_parallel_per_shard) }}
|
||||
matrix: ${{ fromJSON(needs.catalog.outputs.matrix_1) }}
|
||||
steps: *direct_eval_steps
|
||||
|
||||
report:
|
||||
name: Merge attempts and render canonical Evalbook
|
||||
if: always() && !cancelled() && needs.catalog.result == 'success' && needs.build_runner.result == 'success'
|
||||
needs: [authorize, catalog, build_runner, eval_shard_0, eval_shard_1]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
outputs:
|
||||
public_report_ready: ${{ steps.public_report.outputs.ready }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate private eval-repository token
|
||||
id: evals_token
|
||||
env:
|
||||
COMMITPERCLIP_KEY: ${{ secrets.COMMITPERCLIP_KEY }}
|
||||
GH_REPO: paperclipai/paperclip-evals
|
||||
run: |
|
||||
set -euo pipefail
|
||||
token="$(node .github/scripts/get-bot-token.mjs)"
|
||||
echo "::add-mask::$token"
|
||||
echo "value=$token" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
repository: paperclipai/paperclip-evals
|
||||
ref: ${{ needs.authorize.outputs.evals_sha }}
|
||||
path: .paperclip-evals
|
||||
token: ${{ steps.evals_token.outputs.value }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Download immutable campaign catalog
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: runner-protocol-eval-catalog-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-catalog
|
||||
|
||||
- name: Download portable runner and viewer
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: runner-protocol-build-${{ needs.authorize.outputs.target_sha }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-build
|
||||
|
||||
- name: Download every access-controlled cell
|
||||
id: download_cells
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: runner-protocol-eval-${{ github.run_id }}-${{ github.run_attempt }}-*
|
||||
path: downloaded-runner-protocol-evals
|
||||
merge-multiple: false
|
||||
|
||||
- name: Retry cell download after artifact transport failure
|
||||
if: steps.download_cells.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: runner-protocol-eval-${{ github.run_id }}-${{ github.run_attempt }}-*
|
||||
path: downloaded-runner-protocol-evals
|
||||
merge-multiple: false
|
||||
|
||||
- name: Materialize an empty download root when every cell failed early
|
||||
run: mkdir -p downloaded-runner-protocol-evals
|
||||
|
||||
- name: Verify portable viewer
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd runner-protocol-build
|
||||
sha256sum --check runner-protocol-build.tar.gz.sha256
|
||||
mkdir extracted
|
||||
tar --extract --gzip --file runner-protocol-build.tar.gz --directory extracted
|
||||
test -f extracted/dist-issue-thread/index.html
|
||||
|
||||
- name: Aggregate every expected cell, including missing infrastructure cells
|
||||
env:
|
||||
PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
PAPERCLIP_PROTOCOL_EVAL_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }}
|
||||
PAPERCLIP_PROTOCOL_EVALS_SHA: ${{ needs.authorize.outputs.evals_sha }}
|
||||
PAPERCLIP_PROTOCOL_EVAL_WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
node packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs aggregate \
|
||||
--catalog runner-protocol-catalog/runner-protocol-eval-catalog.json \
|
||||
--downloads downloaded-runner-protocol-evals \
|
||||
--evals-root .paperclip-evals \
|
||||
--runs-out runner-protocol-merged/runs \
|
||||
--campaign-out runner-protocol-merged/campaign.json
|
||||
|
||||
- name: Render the access-controlled canonical Evalbook report
|
||||
run: |
|
||||
python3 .paperclip-evals/evals/paperclip-runner/tools/eval_program.py report \
|
||||
--runs-root runner-protocol-merged/runs \
|
||||
--output runner-protocol-merged/report \
|
||||
--viewer-root runner-protocol-build/extracted/dist-issue-thread \
|
||||
--inventory .paperclip-evals/evals/paperclip-runner/inventory.json \
|
||||
--coverage-matrix .paperclip-evals/evals/paperclip-runner/coverage-matrix.json
|
||||
cp runner-protocol-merged/campaign.json runner-protocol-merged/report/campaign.json
|
||||
|
||||
- name: Render the same canonical grid from a public-safe evidence projection
|
||||
run: |
|
||||
node packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs sanitize \
|
||||
--runs-root runner-protocol-merged/runs \
|
||||
--output runner-protocol-merged/public-runs
|
||||
python3 .paperclip-evals/evals/paperclip-runner/tools/eval_program.py report \
|
||||
--runs-root runner-protocol-merged/public-runs \
|
||||
--output runner-protocol-merged/public-report \
|
||||
--viewer-root runner-protocol-build/extracted/dist-issue-thread \
|
||||
--public-viewer \
|
||||
--inventory .paperclip-evals/evals/paperclip-runner/inventory.json \
|
||||
--coverage-matrix .paperclip-evals/evals/paperclip-runner/coverage-matrix.json
|
||||
cp runner-protocol-merged/campaign.json runner-protocol-merged/public-report/campaign.json
|
||||
|
||||
- name: Set up report browser verification
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Verify the actual chat viewer before publication
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
pnpm --filter @paperclipai/paperclip-runner exec playwright install --with-deps chromium
|
||||
node packages/paperclip-runner/scripts/verify-runner-evalbook-viewer.mjs --report-root runner-protocol-merged/public-report --screenshots runner-protocol-merged/viewer-proof
|
||||
node packages/paperclip-runner/scripts/verify-runner-evalbook-viewer.mjs --report-root runner-protocol-merged/report
|
||||
|
||||
- name: Enforce the static public allowlist
|
||||
id: public_report
|
||||
run: |
|
||||
node --input-type=module -e 'import { validatePublicProtocolEvalReport } from "./packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs"; await validatePublicProtocolEvalReport("runner-protocol-merged/public-report", { viewerRoot: "runner-protocol-build/extracted/dist-issue-thread" });'
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add campaign result to the workflow summary
|
||||
run: |
|
||||
{
|
||||
echo '## Runner direct live protocol evals'
|
||||
echo
|
||||
jq -r '"- Cells: \(.totals.passed)/\(.totals.selected) passed\n- Behavior failures: \(.totals.behaviorFailures)\n- Infrastructure failures: \(.totals.infrastructureFailures)\n- Paperclip: `\(.source.paperclip.sha)`\n- Evals: `\(.source.evals.sha)`"' runner-protocol-merged/campaign.json
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload access-controlled canonical Evalbook and raw attempts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-eval-report-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-merged/
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload publisher-only sanitized Evalbook
|
||||
if: steps.public_report.outputs.ready == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-eval-public-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-merged/public-report/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Enforce complete green campaign
|
||||
if: always()
|
||||
run: jq -e '.complete == true and .allPassed == true' runner-protocol-merged/campaign.json >/dev/null
|
||||
|
||||
publish_history:
|
||||
name: Publish immutable Evalbook and mutable campaign index
|
||||
needs: [authorize, catalog, report]
|
||||
if: always() && needs.report.outputs.public_report_ready == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: runner-protocol-eval-history-publish
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: runner-e2e-history
|
||||
url: ${{ steps.publish.outputs.report_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# AWS credentials can execute only the publisher from the trusted workflow revision.
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Download only the sanitized canonical Evalbook
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: runner-protocol-eval-public-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-public-report
|
||||
|
||||
- name: Download the same-run canonical viewer for byte verification
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: runner-protocol-viewer-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-trusted-viewer
|
||||
|
||||
- name: Exchange GitHub OIDC identity for scoped AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.RUNNER_PROTOCOL_EVAL_HISTORY_AWS_ROLE_ARN || vars.RUNNER_E2E_HISTORY_AWS_ROLE_ARN }}
|
||||
aws-region: ${{ vars.RUNNER_PROTOCOL_EVAL_HISTORY_AWS_REGION || vars.RUNNER_E2E_HISTORY_AWS_REGION }}
|
||||
|
||||
- name: Publish versioned report and refresh the root index
|
||||
id: publish
|
||||
env:
|
||||
PAPERCLIP_RUNNER_PROTOCOL_EVAL_PUBLIC_REPORT_DIR: ${{ github.workspace }}/runner-protocol-public-report
|
||||
PAPERCLIP_RUNNER_PROTOCOL_EVAL_VIEWER_DIR: ${{ github.workspace }}/runner-protocol-trusted-viewer
|
||||
RUNNER_PROTOCOL_EVAL_HISTORY_S3_BUCKET: ${{ vars.RUNNER_PROTOCOL_EVAL_HISTORY_S3_BUCKET || vars.RUNNER_E2E_HISTORY_S3_BUCKET }}
|
||||
RUNNER_PROTOCOL_EVAL_HISTORY_PREFIX: ${{ vars.RUNNER_PROTOCOL_EVAL_HISTORY_PREFIX || 'runner-protocol-evals' }}
|
||||
RUNNER_PROTOCOL_EVAL_HISTORY_PUBLIC_BASE_URL: ${{ vars.RUNNER_PROTOCOL_EVAL_HISTORY_PUBLIC_BASE_URL || vars.RUNNER_E2E_HISTORY_PUBLIC_BASE_URL }}
|
||||
run: node packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
name: Storybook Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Repository branch to publish (empty uses the selected workflow branch)"
|
||||
type: string
|
||||
default: ""
|
||||
# Also exposed by Storybook Visual, which is already available on master.
|
||||
workflow_call:
|
||||
inputs:
|
||||
branch:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
name: Authorize Storybook publisher
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
sha: ${{ steps.source.outputs.sha }}
|
||||
branch: ${{ steps.source.outputs.branch }}
|
||||
branch_key: ${{ steps.source.outputs.branch_key }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Require CODEOWNER initiator and rerunner
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
const authorize = require('./.github/scripts/authorize-storybook-deploy.cjs');
|
||||
await authorize({ github, context });
|
||||
|
||||
- name: Pin requested repository branch
|
||||
id: source
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
SOURCE_BRANCH: ${{ inputs.branch }}
|
||||
STORYBOOK_S3_BUCKET: ${{ vars.STORYBOOK_S3_BUCKET }}
|
||||
STORYBOOK_PUBLIC_BASE_URL: ${{ vars.STORYBOOK_PUBLIC_BASE_URL }}
|
||||
with:
|
||||
script: |
|
||||
const branch = process.env.SOURCE_BRANCH || context.ref.slice('refs/heads/'.length);
|
||||
const { data } = await github.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` });
|
||||
if (data.ref !== `refs/heads/${branch}` || data.object.type !== 'commit') {
|
||||
throw new Error('Select an existing branch in this repository.');
|
||||
}
|
||||
// With no source override, preserve the exact dispatched commit.
|
||||
const sha = process.env.SOURCE_BRANCH ? data.object.sha : context.sha;
|
||||
const { storybookDestination } = require('./.github/scripts/storybook-destination.cjs');
|
||||
const destination = storybookDestination({ branch, sha,
|
||||
runId: context.runId, runAttempt: process.env.GITHUB_RUN_ATTEMPT,
|
||||
bucket: process.env.STORYBOOK_S3_BUCKET, baseUrl: process.env.STORYBOOK_PUBLIC_BASE_URL });
|
||||
core.setOutput('sha', sha);
|
||||
core.setOutput('branch_key', destination.branchKey);
|
||||
core.setOutput('branch', branch);
|
||||
|
||||
build:
|
||||
name: Build selected branch Storybook
|
||||
permissions: {}
|
||||
needs: authorize
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
outputs:
|
||||
artifact_name: ${{ steps.artifact.outputs.name }}
|
||||
env:
|
||||
STORYBOOK_DISABLE_TELEMETRY: "1"
|
||||
steps:
|
||||
- name: Download public source without repository credentials
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
|
||||
run: |
|
||||
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]
|
||||
curl --fail --silent --show-error --location --retry 3 \
|
||||
"https://codeload.github.com/paperclipai/paperclip/tar.gz/$SOURCE_SHA" \
|
||||
--output "$RUNNER_TEMP/source.tar.gz"
|
||||
tar -xzf "$RUNNER_TEMP/source.tar.gz" --strip-components=1
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
- run: pnpm build-storybook
|
||||
- name: Record source and validate output
|
||||
id: artifact
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
|
||||
SOURCE_BRANCH: ${{ needs.authorize.outputs.branch }}
|
||||
run: |
|
||||
test -s ui/storybook-static/index.html
|
||||
test -s ui/storybook-static/iframe.html
|
||||
test -s ui/storybook-static/index.json
|
||||
jq -n --arg sha "$SOURCE_SHA" --arg branch "$SOURCE_BRANCH" \
|
||||
'{sha: $sha, branch: $branch}' > ui/storybook-static/deployment.json
|
||||
echo "name=storybook-deploy-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: ui/storybook-static
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
deploy:
|
||||
name: Publish branch Storybook to S3
|
||||
needs: [authorize, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: storybook-deploy-${{ needs.authorize.outputs.branch_key }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: storybook-deploy
|
||||
url: ${{ steps.deployment.outputs.url }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Recheck CODEOWNER access before publishing
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
const authorize = require('./.github/scripts/authorize-storybook-deploy.cjs');
|
||||
await authorize({ github, context });
|
||||
- name: Download the successful build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ needs.build.outputs.artifact_name }}
|
||||
path: storybook-static
|
||||
- name: Assume the Storybook-only uploader role
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.STORYBOOK_AWS_ROLE_ARN }}
|
||||
aws-region: ${{ vars.STORYBOOK_AWS_REGION }}
|
||||
role-duration-seconds: 900
|
||||
- name: Publish this branch preview
|
||||
id: deployment
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
|
||||
SOURCE_BRANCH: ${{ needs.authorize.outputs.branch }}
|
||||
STORYBOOK_S3_BUCKET: ${{ vars.STORYBOOK_S3_BUCKET }}
|
||||
STORYBOOK_PUBLIC_BASE_URL: ${{ vars.STORYBOOK_PUBLIC_BASE_URL }}
|
||||
run: node .github/scripts/publish-storybook.cjs
|
||||
- name: Verify public build and stable branch URL
|
||||
env:
|
||||
BUILD_URL: ${{ steps.deployment.outputs.build_url }}
|
||||
BRANCH_URL: ${{ steps.deployment.outputs.url }}
|
||||
SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
|
||||
run: node .github/scripts/verify-storybook.cjs
|
||||
- name: Upload deployment links
|
||||
if: ${{ !cancelled() && steps.deployment.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: storybook-deployment-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.deployment.outputs.report_path }}
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
|
@ -3,6 +3,16 @@ name: Storybook Visual
|
|||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Repository branch to publish (deployment only; empty uses the selected workflow branch)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
deploy_preview:
|
||||
description: "Publish this branch to S3/CloudFront instead of running visual tests (CODEOWNERS only)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
update_snapshots:
|
||||
description: "Generate updated snapshots and a baseline review bundle"
|
||||
required: false
|
||||
|
|
@ -18,7 +28,7 @@ on:
|
|||
- labeled
|
||||
|
||||
concurrency:
|
||||
group: storybook-visual-${{ github.event.pull_request.number || github.ref }}
|
||||
group: storybook-visual-${{ inputs.deploy_preview && github.run_id || 'visual' }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
|
|
@ -28,7 +38,7 @@ jobs:
|
|||
visual:
|
||||
name: Storybook visual regression
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_dispatch' && !inputs.deploy_preview) ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'storybook-visual'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
|
|
@ -100,3 +110,13 @@ jobs:
|
|||
path: tests/storybook-visual/baseline-review/snapshots.tgz
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
preview:
|
||||
name: Deploy selected branch Storybook
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.deploy_preview
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/storybook-deploy.yml
|
||||
with:
|
||||
branch: ${{ inputs.branch }}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ node_modules/
|
|||
**/node_modules/
|
||||
dist/
|
||||
dist-preview/
|
||||
dist-flow-preview/
|
||||
packages/paperclip-runner/runner/target/
|
||||
ui/storybook-static/
|
||||
.env
|
||||
|
|
|
|||
10
DESIGN.md
10
DESIGN.md
|
|
@ -35,6 +35,16 @@ Existing tiers already in index.css (~80+ tokens) — extraction maps to these o
|
|||
7. **Words are part of the system.** One name per concept across the entire UI — the canonical term is *task* (never *issue* or *ticket* in copy, labels, or empty states). Buttons name the action ("Approve hire," not "Submit"). Errors say what happened and what to do. Empty states say what to do first. **Note:** enforcing the task rename is a visible change and is explicitly OUT of the zero-visual-change extraction run; it happens in its own follow-up run.
|
||||
8. **Agent-modifiable by design.** The system must be changeable via instructions: single token source, lint rules that enforce it, and this document kept current. A correct change should be expressible as "edit tokens + run checks," not "visit 40 files."
|
||||
|
||||
## Contextual feedback
|
||||
|
||||
Do not show a toast for task or run state already visible on the current screen.
|
||||
This includes descendant runs represented by the open subtree. Show local action
|
||||
results in place; keep failures actionable inline. Notifications for other work
|
||||
remain useful. Expected cancellation is neutral gray, not an error. The composer's Stop action stops the current response and leaves the composer available for a new message. Pause work is a separate explicit task or subtree action. A paused task replaces the composer with an amber takeover. It says “Task is
|
||||
paused.” and “Resume this task to send a message.” with a “Resume task” action.
|
||||
Subtrees use “Subtree is paused.” and “Resume subtree.” The takeover cannot be
|
||||
dismissed, retains drafts, and hides message inputs until the pause is released.
|
||||
|
||||
## Enforcement (what "compliant" means for the extraction run)
|
||||
|
||||
- **Zero visual change is proven, not promised:** Storybook visual snapshots are baselined before any refactor, and all snapshots match baseline after it. A change that alters rendered output must be intentional and human-approved.
|
||||
|
|
|
|||
60
Dockerfile
60
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,51 @@ 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
|
||||
|
||||
# Pin the recipe generator and its dependency lockfile. It is a build-only tool
|
||||
# and uses the same package-owned compiler as both native build stages.
|
||||
FROM rust-toolchain AS rust-chef
|
||||
RUN cd /tmp/runner-toolchain && cargo install cargo-chef --version 0.1.73 --locked
|
||||
|
||||
FROM rust-chef AS runner-plan
|
||||
WORKDIR /app/packages/paperclip-runner
|
||||
COPY packages/paperclip-runner/rust-toolchain.toml ./
|
||||
COPY packages/paperclip-runner/runner ./runner
|
||||
RUN cd runner && cargo chef prepare --recipe-path /tmp/runner-recipe.json
|
||||
|
||||
FROM rust-chef AS runner-deps
|
||||
WORKDIR /app/packages/paperclip-runner/runner
|
||||
COPY packages/paperclip-runner/rust-toolchain.toml ../
|
||||
# The recipe changes only when dependency manifests, the lockfile, or target
|
||||
# metadata change. Source edits can reuse this compiled dependency layer.
|
||||
COPY --from=runner-plan /tmp/runner-recipe.json /tmp/runner-recipe.json
|
||||
RUN cargo chef cook --release --locked --package paperclip-runner-core --bin paperclip-runnerd --recipe-path /tmp/runner-recipe.json \
|
||||
&& find . -mindepth 1 -maxdepth 1 ! -name target -exec rm -rf {} +
|
||||
|
||||
FROM runner-deps 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 +146,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 +168,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 \
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -336,6 +336,25 @@ To try Paperclip without installing anything permanently:
|
|||
npx --registry https://registry.npmjs.org paperclipai onboard --yes
|
||||
```
|
||||
|
||||
For an isolated manual test instance that is already initialized with a CEO
|
||||
agent, use `test-drive`. It stays in the foreground, never installs a service
|
||||
or creates a first task, and opens the browser only after setup succeeds:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=... npx paperclipai test-drive
|
||||
OPENAI_API_KEY=... npx paperclipai test-drive --harness codex
|
||||
OPENROUTER_API_KEY=... npx paperclipai test-drive \
|
||||
--harness opencode \
|
||||
--model openrouter/anthropic/claude-sonnet-4.5
|
||||
```
|
||||
|
||||
Each run without `--data-dir` gets a unique, retained temporary directory; its
|
||||
absolute path is printed at startup. Pass `--data-dir` to reuse one, or
|
||||
`--no-browser` to leave the initialized instance unopened. When invoked from a
|
||||
linked Git worktree, `test-drive` also enables task execution in that worktree.
|
||||
See [`doc/CLI.md`](doc/CLI.md#isolated-manual-test-drives) for credential and
|
||||
reuse behavior.
|
||||
|
||||
> **Troubleshooting: private npm registry `.npmrc`**
|
||||
>
|
||||
> If this fails with an `E404` for `paperclipai` (or similar) and you use a private npm registry (for example GitHub Packages) via a global `~/.npmrc`, `npx` may be resolving `paperclipai` against that private registry instead of the public npm registry.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
|
@ -8,7 +9,18 @@ import type { PaperclipConfig } from "../config/schema.js";
|
|||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createTempConfig(): string {
|
||||
async function availablePort(): Promise<number> {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address() as net.AddressInfo;
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
||||
return address.port;
|
||||
}
|
||||
|
||||
function createTempConfig(serverPort: number): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
const runtimeRoot = path.join(root, "runtime");
|
||||
|
|
@ -38,7 +50,7 @@ function createTempConfig(): string {
|
|||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3199,
|
||||
port: serverPort,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
|
|
@ -87,7 +99,7 @@ describe("doctor", () => {
|
|||
});
|
||||
|
||||
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
|
||||
const configPath = createTempConfig();
|
||||
const configPath = createTempConfig(await availablePort());
|
||||
|
||||
const summary = await doctor({
|
||||
config: configPath,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { detectGitWorkspaceInfo, isLinkedGitWorktree } from "../commands/git-workspace.js";
|
||||
|
||||
const cleanupDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupDirectories.length > 0) {
|
||||
fs.rmSync(cleanupDirectories.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("Git worktree detection", () => {
|
||||
it("distinguishes a linked worktree from the primary checkout and non-Git paths", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-git-workspace-"));
|
||||
cleanupDirectories.push(root);
|
||||
const primary = path.join(root, "primary");
|
||||
const linked = path.join(root, "linked");
|
||||
fs.mkdirSync(primary);
|
||||
execFileSync("git", ["init"], { cwd: primary, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: primary });
|
||||
execFileSync("git", ["config", "user.name", "Paperclip Test"], { cwd: primary });
|
||||
fs.writeFileSync(path.join(primary, "README.md"), "test\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: primary });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: primary, stdio: "ignore" });
|
||||
execFileSync("git", ["worktree", "add", "-b", "linked-test", linked], {
|
||||
cwd: primary,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
const primaryInfo = detectGitWorkspaceInfo(primary);
|
||||
const linkedInfo = detectGitWorkspaceInfo(linked);
|
||||
expect(primaryInfo?.gitDir).toBe(primaryInfo?.commonDir);
|
||||
expect(linkedInfo?.gitDir).not.toBe(linkedInfo?.commonDir);
|
||||
expect(isLinkedGitWorktree(primary)).toBe(false);
|
||||
expect(isLinkedGitWorktree(linked)).toBe(true);
|
||||
expect(detectGitWorkspaceInfo(root)).toBeNull();
|
||||
expect(isLinkedGitWorktree(root)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import fs from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
|
@ -121,6 +122,29 @@ describe("managed install store", () => {
|
|||
expect(fs.statSync(rcPath).mode & 0o777).toBe(0o640);
|
||||
});
|
||||
|
||||
it("uses the pinned Node for child tools even with an older node first on the service PATH", () => {
|
||||
const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js");
|
||||
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
|
||||
fs.writeFileSync(entrypoint, `console.log(require("node:child_process").execFileSync("node", ["-p", "process.execPath"], {encoding: "utf8"}).trim())`);
|
||||
const oldBin = path.join(root, "old-bin");
|
||||
fs.mkdirSync(oldBin);
|
||||
fs.writeFileSync(path.join(oldBin, "node"), "#!/bin/sh\nexit 42\n", { mode: 0o755 });
|
||||
writeManagedShim(paths);
|
||||
const output = execFileSync(paths.shimPath, [], { env: { ...process.env, PATH: oldBin }, encoding: "utf8" });
|
||||
expect(fs.realpathSync(output.trim())).toBe(fs.realpathSync(process.execPath));
|
||||
expect(removeManagedShim(paths)).toBe(true);
|
||||
});
|
||||
|
||||
it("upgrades and removes the original managed shim format", () => {
|
||||
writeManagedShim(paths);
|
||||
const original = fs.readFileSync(paths.shimPath, "utf8").split("\n").filter((line) => !line.startsWith("export PATH=")).join("\n");
|
||||
fs.writeFileSync(paths.shimPath, original);
|
||||
writeManagedShim(paths);
|
||||
expect(fs.readFileSync(paths.shimPath, "utf8")).toContain("export PATH=");
|
||||
fs.writeFileSync(paths.shimPath, original);
|
||||
expect(removeManagedShim(paths)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects marker substrings that are not the exact managed shim format", () => {
|
||||
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
|
||||
fs.writeFileSync(paths.shimPath, `#!/bin/sh\necho '${MANAGED_SHIM_MARKER}'\n`);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const PRODUCT_ID = "77777777-7777-4777-8777-777777777777";
|
|||
const INTERACTION_ID = "88888888-8888-4888-8888-888888888888";
|
||||
const HOLD_ID = "99999999-9999-4999-8999-999999999999";
|
||||
const ATTACHMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const SECOND_ATTACHMENT_ID = "abababab-abab-4bab-8bab-abababababab";
|
||||
const LABEL_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
|
||||
function createProgram(): Command {
|
||||
|
|
@ -61,6 +62,24 @@ describe("issue subresource commands", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("binds explicit uploaded attachments when adding a comment", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run([
|
||||
"issue", "comment", ISSUE_ID,
|
||||
"--body", "The requested files are ready.",
|
||||
"--attachment-id", ATTACHMENT_ID, SECOND_ATTACHMENT_ID,
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/issues/${ISSUE_ID}/comments`);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
|
||||
body: "The requested files are ready.",
|
||||
attachmentIds: [ATTACHMENT_ID, SECOND_ATTACHMENT_ID],
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps comments, approvals, markers, and recovery action endpoints", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,602 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent, Company, InstanceExperimentalSettings } from "@paperclipai/shared";
|
||||
import {
|
||||
assertTestDriveDatabaseIsolation,
|
||||
bootstrapTestDrive,
|
||||
prepareTestDriveEnvironment,
|
||||
reconcileTestDriveWorktreeExecution,
|
||||
redactTestDriveArgv,
|
||||
redactTestDriveText,
|
||||
resolveTestDriveBootstrap,
|
||||
resolveTestDriveDataDir,
|
||||
testDriveCommand,
|
||||
type TestDriveApi,
|
||||
type TestDriveHarness,
|
||||
} from "../commands/test-drive.js";
|
||||
import type { RunOptions, StartedServer } from "../commands/run.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const cleanupDirectories: string[] = [];
|
||||
|
||||
function company(id = "company-1", name = "Test Company"): Company {
|
||||
return { id, name } as Company;
|
||||
}
|
||||
|
||||
function agent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
...overrides,
|
||||
} as Agent;
|
||||
}
|
||||
|
||||
function settings(overrides: Partial<InstanceExperimentalSettings> = {}): InstanceExperimentalSettings {
|
||||
return {
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
...overrides,
|
||||
} as InstanceExperimentalSettings;
|
||||
}
|
||||
|
||||
function freshBootstrapApi(input?: { failAgent?: boolean }) {
|
||||
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
||||
const api = {
|
||||
get: vi.fn(async <T>(requestPath: string) => {
|
||||
calls.push({ method: "GET", path: requestPath });
|
||||
return [] as T;
|
||||
}),
|
||||
post: vi.fn(async <T>(requestPath: string, body?: unknown) => {
|
||||
calls.push({ method: "POST", path: requestPath, body });
|
||||
if (requestPath === "/api/companies") return company() as T;
|
||||
if (requestPath.endsWith("/agents")) {
|
||||
if (input?.failAgent) throw new Error("agent setup failed");
|
||||
const payload = body as { name: string; adapterType: Agent["adapterType"]; adapterConfig: Record<string, unknown> };
|
||||
return agent({
|
||||
name: payload.name,
|
||||
adapterType: payload.adapterType,
|
||||
adapterConfig: payload.adapterConfig,
|
||||
}) as T;
|
||||
}
|
||||
return { ok: true } as T;
|
||||
}),
|
||||
patch: vi.fn(async <T>() => ({ ok: true }) as T),
|
||||
delete: vi.fn(async <T>(requestPath: string) => {
|
||||
calls.push({ method: "DELETE", path: requestPath });
|
||||
return { ok: true } as T;
|
||||
}),
|
||||
} as TestDriveApi;
|
||||
return { api, calls };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
while (cleanupDirectories.length > 0) {
|
||||
fs.rmSync(cleanupDirectories.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("test-drive data isolation", () => {
|
||||
it("creates unique retained OS temporary directories and reports absolute paths", () => {
|
||||
const first = resolveTestDriveDataDir();
|
||||
const second = resolveTestDriveDataDir();
|
||||
cleanupDirectories.push(first, second);
|
||||
|
||||
expect(path.isAbsolute(first)).toBe(true);
|
||||
expect(path.dirname(first)).toBe(os.tmpdir());
|
||||
expect(first).not.toBe(second);
|
||||
expect(fs.existsSync(first)).toBe(true);
|
||||
expect(fs.existsSync(second)).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves an explicit reusable directory without resetting it", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-explicit-"));
|
||||
cleanupDirectories.push(root);
|
||||
const marker = path.join(root, "keep.txt");
|
||||
fs.writeFileSync(marker, "keep");
|
||||
|
||||
expect(resolveTestDriveDataDir(root)).toBe(path.resolve(root));
|
||||
expect(fs.readFileSync(marker, "utf8")).toBe("keep");
|
||||
});
|
||||
|
||||
it("discards inherited Paperclip routing while preserving a custom key source", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-env-"));
|
||||
cleanupDirectories.push(root);
|
||||
process.env.PAPERCLIP_HOME = "/normal/home";
|
||||
process.env.PAPERCLIP_CONFIG = "/normal/config.json";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_TEST_PROVIDER_KEY = "secret-value";
|
||||
process.env.DATABASE_URL = "postgres://normal-instance";
|
||||
|
||||
const prepared = await prepareTestDriveEnvironment(
|
||||
{ dataDir: root, apiKeyEnv: "PAPERCLIP_TEST_PROVIDER_KEY" },
|
||||
os.tmpdir(),
|
||||
);
|
||||
|
||||
expect(prepared.dataDir).toBe(path.resolve(root));
|
||||
expect(prepared.linkedWorktree).toBe(false);
|
||||
expect(process.env.PAPERCLIP_HOME).toBe(path.resolve(root));
|
||||
expect(process.env.PAPERCLIP_CONFIG).toBe(
|
||||
path.join(path.resolve(root), "instances", "default", "config.json"),
|
||||
);
|
||||
expect(process.env.PAPERCLIP_IN_WORKTREE).toBe("false");
|
||||
expect(process.env.PAPERCLIP_DISABLE_CWD_ENV_FILE).toBe("true");
|
||||
expect(process.env.PAPERCLIP_DEPLOYMENT_MODE).toBe("local_trusted");
|
||||
expect(process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("private");
|
||||
expect(process.env.PAPERCLIP_BIND).toBe("loopback");
|
||||
expect(process.env.HOST).toBe("127.0.0.1");
|
||||
expect(process.env.PAPERCLIP_TEST_PROVIDER_KEY).toBe("secret-value");
|
||||
expect(process.env.DATABASE_URL).toBeUndefined();
|
||||
expect(Number(process.env.PORT)).toBeGreaterThanOrEqual(3100);
|
||||
});
|
||||
|
||||
it.each(["DATABASE_URL", "DATABASE_MIGRATION_URL"])(
|
||||
"rejects %s loaded from the isolated directory",
|
||||
(variable) => {
|
||||
const readConfigFile = vi.fn(() => null);
|
||||
expect(() => assertTestDriveDatabaseIsolation(
|
||||
undefined,
|
||||
{ [variable]: "postgres://external-database" },
|
||||
readConfigFile,
|
||||
)).toThrow(/requires its isolated embedded database/);
|
||||
expect(readConfigFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an explicitly reused PostgreSQL configuration", () => {
|
||||
const externalConfig = {
|
||||
database: { mode: "postgres" },
|
||||
} as PaperclipConfig;
|
||||
expect(() => assertTestDriveDatabaseIsolation(
|
||||
"/tmp/reused/config.json",
|
||||
{},
|
||||
() => externalConfig,
|
||||
)).toThrow(/cannot reuse.*external PostgreSQL database/);
|
||||
});
|
||||
|
||||
it("accepts an embedded configuration", () => {
|
||||
const embeddedConfig = {
|
||||
database: { mode: "embedded-postgres" },
|
||||
} as PaperclipConfig;
|
||||
expect(() => assertTestDriveDatabaseIsolation(
|
||||
"/tmp/reused/config.json",
|
||||
{},
|
||||
() => embeddedConfig,
|
||||
)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("test-drive bootstrap validation", () => {
|
||||
it("uses Claude defaults and the canonical environment credential", () => {
|
||||
const resolved = resolveTestDriveBootstrap({}, { ANTHROPIC_API_KEY: "anthropic-secret" });
|
||||
expect(resolved).toMatchObject({
|
||||
companyName: "Test Company",
|
||||
agentName: "CEO",
|
||||
adapterType: "claude_local",
|
||||
credentialTarget: "ANTHROPIC_API_KEY",
|
||||
credential: "anthropic-secret",
|
||||
});
|
||||
expect(resolved.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps all harnesses and leaves Claude/Codex models optional", () => {
|
||||
const cases: Array<[TestDriveHarness, string, string]> = [
|
||||
["claude", "claude_local", "ANTHROPIC_API_KEY"],
|
||||
["codex", "codex_local", "OPENAI_API_KEY"],
|
||||
["opencode", "opencode_local", "OPENROUTER_API_KEY"],
|
||||
];
|
||||
for (const [harness, adapterType, credentialTarget] of cases) {
|
||||
const resolved = resolveTestDriveBootstrap(
|
||||
{
|
||||
harness,
|
||||
...(harness === "opencode" ? { model: "openrouter/anthropic/claude-sonnet-4.5" } : {}),
|
||||
},
|
||||
{ [credentialTarget]: "provider-secret" },
|
||||
);
|
||||
expect(resolved.adapterType).toBe(adapterType);
|
||||
expect(resolved.credentialTarget).toBe(credentialTarget);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an OpenRouter OpenCode model and preserves every model path segment", () => {
|
||||
expect(() => resolveTestDriveBootstrap(
|
||||
{ harness: "opencode" },
|
||||
{ OPENROUTER_API_KEY: "secret" },
|
||||
)).toThrow(/require --model openrouter/);
|
||||
for (const model of ["anthropic/claude", "openrouter/", "openrouter//claude", "openrouter/a/"]) {
|
||||
expect(() => resolveTestDriveBootstrap(
|
||||
{ harness: "opencode", model },
|
||||
{ OPENROUTER_API_KEY: "secret" },
|
||||
)).toThrow(/require --model openrouter/);
|
||||
}
|
||||
|
||||
const model = "openrouter/publisher/family/model";
|
||||
expect(resolveTestDriveBootstrap(
|
||||
{ harness: "opencode", model },
|
||||
{ OPENROUTER_API_KEY: "secret" },
|
||||
).model).toBe(model);
|
||||
});
|
||||
|
||||
it("supports custom source variables while retaining the canonical target", () => {
|
||||
const resolved = resolveTestDriveBootstrap(
|
||||
{
|
||||
harness: "opencode",
|
||||
model: "openrouter/anthropic/claude-sonnet-4.5",
|
||||
apiKeyEnv: "MY_OPENROUTER_KEY",
|
||||
},
|
||||
{ MY_OPENROUTER_KEY: "custom-secret" },
|
||||
);
|
||||
expect(resolved.credential).toBe("custom-secret");
|
||||
expect(resolved.credentialSource).toBe("MY_OPENROUTER_KEY");
|
||||
expect(resolved.credentialTarget).toBe("OPENROUTER_API_KEY");
|
||||
});
|
||||
|
||||
it("accepts a literal key and gives it precedence over canonical environment lookup", () => {
|
||||
const resolved = resolveTestDriveBootstrap(
|
||||
{ apiKey: "literal-secret" },
|
||||
{ ANTHROPIC_API_KEY: "environment-secret" },
|
||||
);
|
||||
expect(resolved.credential).toBe("literal-secret");
|
||||
expect(resolved.credentialSource).toBe("--api-key");
|
||||
});
|
||||
|
||||
it("rejects mutually exclusive key inputs", () => {
|
||||
expect(() => resolveTestDriveBootstrap({
|
||||
apiKey: "literal-secret",
|
||||
apiKeyEnv: "ANTHROPIC_API_KEY",
|
||||
}, { ANTHROPIC_API_KEY: "environment-secret" })).toThrow(/mutually exclusive/);
|
||||
});
|
||||
|
||||
it("rejects invalid key variable names and redacts credentials", () => {
|
||||
expect(() => resolveTestDriveBootstrap({
|
||||
apiKeyEnv: "NOT-A-VALID-NAME",
|
||||
}, { ANTHROPIC_API_KEY: "env-secret" })).toThrow(/valid environment variable/);
|
||||
expect(redactTestDriveText(
|
||||
"literal-secret, custom-secret, and env-secret must never appear",
|
||||
["literal-secret", "custom-secret", "env-secret"],
|
||||
)).toBe("[REDACTED], [REDACTED], and [REDACTED] must never appear");
|
||||
});
|
||||
|
||||
it("removes literal keys from the JavaScript argv view", () => {
|
||||
const splitArgv = ["node", "paperclipai", "test-drive", "--api-key", "literal-secret"];
|
||||
const joinedArgv = ["node", "paperclipai", "test-drive", "--api-key=literal-secret"];
|
||||
|
||||
redactTestDriveArgv("literal-secret", splitArgv);
|
||||
redactTestDriveArgv("literal-secret", joinedArgv);
|
||||
|
||||
expect(splitArgv).toEqual(["node", "paperclipai", "test-drive", "--api-key", "[REDACTED]"]);
|
||||
expect(joinedArgv).toEqual(["node", "paperclipai", "test-drive", "--api-key=[REDACTED]"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("test-drive API bootstrap", () => {
|
||||
it.each([
|
||||
["claude", "claude_local", "ANTHROPIC_API_KEY", undefined],
|
||||
["codex", "codex_local", "OPENAI_API_KEY", undefined],
|
||||
["opencode", "opencode_local", "OPENROUTER_API_KEY", "openrouter/anthropic/claude-sonnet-4.5"],
|
||||
] as const)("creates exactly one company and one CEO for %s", async (
|
||||
harness,
|
||||
adapterType,
|
||||
credentialTarget,
|
||||
model,
|
||||
) => {
|
||||
const { api, calls } = freshBootstrapApi();
|
||||
const result = await bootstrapTestDrive({
|
||||
api,
|
||||
options: { harness, ...(model ? { model } : {}) },
|
||||
linkedWorktree: false,
|
||||
instanceId: "default",
|
||||
env: { [credentialTarget]: "secret" },
|
||||
});
|
||||
|
||||
expect(result.reused).toBe(false);
|
||||
expect(result.agent?.role).toBe("ceo");
|
||||
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([
|
||||
"GET /api/companies",
|
||||
"POST /api/companies",
|
||||
"POST /api/companies/company-1/user-secret-definitions",
|
||||
"POST /api/companies/company-1/me/user-secrets",
|
||||
"POST /api/companies/company-1/agents",
|
||||
]);
|
||||
const secretValueCall = calls.find((call) => call.path.endsWith("/me/user-secrets"));
|
||||
expect(secretValueCall?.body).toEqual({ definitionKey: credentialTarget, value: "secret" });
|
||||
const agentCall = calls.find((call) => call.path.endsWith("/agents"));
|
||||
expect(agentCall?.body).toEqual({
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
adapterType,
|
||||
adapterConfig: {
|
||||
...(model ? { model } : {}),
|
||||
env: {
|
||||
[credentialTarget]: {
|
||||
type: "user_secret_ref",
|
||||
key: credentialTarget,
|
||||
version: "latest",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(calls.some((call) => /issues|projects|goals|tasks|heartbeat/.test(call.path))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid OpenCode configuration before creating a company", async () => {
|
||||
const { api, calls } = freshBootstrapApi();
|
||||
await expect(bootstrapTestDrive({
|
||||
api,
|
||||
options: { harness: "opencode" },
|
||||
linkedWorktree: false,
|
||||
instanceId: "default",
|
||||
env: { OPENROUTER_API_KEY: "secret" },
|
||||
})).rejects.toThrow(/require --model openrouter/);
|
||||
expect(calls).toEqual([{ method: "GET", path: "/api/companies" }]);
|
||||
});
|
||||
|
||||
it("preserves seeded data and ignores every bootstrap flag", async () => {
|
||||
const get = vi.fn(async <T>(requestPath: string) => {
|
||||
if (requestPath === "/api/companies") return [company("existing", "Existing Company")] as T;
|
||||
throw new Error(`Unexpected GET ${requestPath}`);
|
||||
});
|
||||
const api = {
|
||||
get,
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as TestDriveApi;
|
||||
|
||||
const result = await bootstrapTestDrive({
|
||||
api,
|
||||
options: { harness: "opencode", companyName: "Ignored" },
|
||||
linkedWorktree: false,
|
||||
instanceId: "default",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ reused: true, company: { id: "existing" }, agent: null });
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
expect(api.patch).not.toHaveBeenCalled();
|
||||
expect(api.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes only the newly-created company when fresh bootstrap fails", async () => {
|
||||
const { api, calls } = freshBootstrapApi({ failAgent: true });
|
||||
await expect(bootstrapTestDrive({
|
||||
api,
|
||||
options: {},
|
||||
linkedWorktree: false,
|
||||
instanceId: "default",
|
||||
env: { ANTHROPIC_API_KEY: "secret" },
|
||||
})).rejects.toThrow("agent setup failed");
|
||||
expect(calls.at(-1)).toEqual({ method: "DELETE", path: "/api/companies/company-1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("test-drive worktree setting reconciliation", () => {
|
||||
function worktreeApi(initial: InstanceExperimentalSettings, instanceId = "test-instance") {
|
||||
let current = initial;
|
||||
const patchBodies: unknown[] = [];
|
||||
const api = {
|
||||
get: vi.fn(async <T>() => current as T),
|
||||
patch: vi.fn(async <T>(_path: string, body?: unknown) => {
|
||||
patchBodies.push(body);
|
||||
const enabled = (body as { enableWorktreeRunExecution: boolean }).enableWorktreeRunExecution;
|
||||
current = settings({
|
||||
...current,
|
||||
enableWorktreeRunExecution: enabled,
|
||||
worktreeRunExecutionActivatedAt: enabled ? "2026-09-05T12:00:00.000Z" : null,
|
||||
worktreeRunExecutionActivationInstanceId: enabled ? instanceId : null,
|
||||
});
|
||||
return current as T;
|
||||
}),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as TestDriveApi;
|
||||
return { api, patchBodies };
|
||||
}
|
||||
|
||||
it("enables a disabled setting", async () => {
|
||||
const { api, patchBodies } = worktreeApi(settings());
|
||||
await reconcileTestDriveWorktreeExecution(api, "test-instance");
|
||||
expect(patchBodies).toEqual([{ enableWorktreeRunExecution: true }]);
|
||||
});
|
||||
|
||||
it("leaves a correctly armed setting unchanged", async () => {
|
||||
const { api, patchBodies } = worktreeApi(settings({
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: "2026-09-05T11:00:00.000Z",
|
||||
worktreeRunExecutionActivationInstanceId: "test-instance",
|
||||
}));
|
||||
await reconcileTestDriveWorktreeExecution(api, "test-instance");
|
||||
expect(patchBodies).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
settings({ enableWorktreeRunExecution: true }),
|
||||
settings({
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: "2026-09-05T11:00:00.000Z",
|
||||
worktreeRunExecutionActivationInstanceId: "another-instance",
|
||||
}),
|
||||
])("rearms missing or mismatched activation metadata", async (initial) => {
|
||||
const { api, patchBodies } = worktreeApi(initial);
|
||||
await reconcileTestDriveWorktreeExecution(api, "test-instance");
|
||||
expect(patchBodies).toEqual([
|
||||
{ enableWorktreeRunExecution: false },
|
||||
{ enableWorktreeRunExecution: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails when the setting cannot be armed for this instance", async () => {
|
||||
const api = {
|
||||
get: vi.fn(async <T>() => settings() as T),
|
||||
patch: vi.fn(async <T>() => settings() as T),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as TestDriveApi;
|
||||
await expect(reconcileTestDriveWorktreeExecution(api, "test-instance"))
|
||||
.rejects.toThrow(/Could not arm/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("test-drive foreground lifecycle", () => {
|
||||
const server: StartedServer = {
|
||||
apiUrl: "http://127.0.0.1:3100/api",
|
||||
databaseUrl: "postgres://embedded",
|
||||
host: "127.0.0.1",
|
||||
listenPort: 3100,
|
||||
};
|
||||
|
||||
it("skips service-manager integration for an auto-created directory and opens after initialization", async () => {
|
||||
process.env.PAPERCLIP_HOME = "/tmp/test-drive-lifecycle";
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
process.env.ANTHROPIC_API_KEY = "secret";
|
||||
const events: string[] = [];
|
||||
let runOptions: RunOptions | undefined;
|
||||
const api = {
|
||||
get: vi.fn(async <T>() => {
|
||||
events.push("initialized");
|
||||
return [company("existing", "Existing")] as T;
|
||||
}),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as TestDriveApi;
|
||||
|
||||
await testDriveCommand({}, {
|
||||
run: async (options) => {
|
||||
runOptions = options;
|
||||
events.push("listening");
|
||||
await options.afterStart?.(server);
|
||||
},
|
||||
createApi: () => api,
|
||||
openBrowser: async () => {
|
||||
events.push("browser");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(runOptions).toMatchObject({
|
||||
yes: true,
|
||||
bind: "loopback",
|
||||
installService: false,
|
||||
skipServiceManagerCheck: true,
|
||||
introLabel: "paperclipai test-drive",
|
||||
});
|
||||
expect(events).toEqual(["listening", "initialized", "browser"]);
|
||||
});
|
||||
|
||||
it("retains the managed-instance collision guard for an explicitly reused directory", async () => {
|
||||
process.env.PAPERCLIP_HOME = "/tmp/test-drive-reused";
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
let runOptions: RunOptions | undefined;
|
||||
const api = {
|
||||
get: vi.fn(async <T>() => [company("existing", "Existing")] as T),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as TestDriveApi;
|
||||
|
||||
await testDriveCommand({ dataDir: "/tmp/test-drive-reused", browser: false }, {
|
||||
run: async (options) => {
|
||||
runOptions = options;
|
||||
await options.afterStart?.(server);
|
||||
},
|
||||
createApi: () => api,
|
||||
openBrowser: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
expect(runOptions?.skipServiceManagerCheck).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the credential snapshot captured before downstream server initialization", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-credential-"));
|
||||
cleanupDirectories.push(root);
|
||||
process.env.PAPERCLIP_HOME = root;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
process.env.ANTHROPIC_API_KEY = "upstream-secret";
|
||||
const { api, calls } = freshBootstrapApi();
|
||||
|
||||
await testDriveCommand({ browser: false }, {
|
||||
run: async (options) => {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
await options.afterStart?.(server);
|
||||
},
|
||||
createApi: () => api,
|
||||
openBrowser: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
const secretValueCall = calls.find((call) => call.path.endsWith("/me/user-secrets"));
|
||||
expect(secretValueCall?.body).toEqual({
|
||||
definitionKey: "ANTHROPIC_API_KEY",
|
||||
value: "upstream-secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts a literal key from downstream errors", async () => {
|
||||
process.env.PAPERCLIP_HOME = "/tmp/test-drive-redaction";
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
|
||||
await expect(testDriveCommand({
|
||||
apiKey: "literal-secret",
|
||||
browser: false,
|
||||
}, {
|
||||
run: async () => {
|
||||
throw new Error("downstream rejected literal-secret");
|
||||
},
|
||||
createApi: () => freshBootstrapApi().api,
|
||||
openBrowser: vi.fn(async () => true),
|
||||
})).rejects.toThrow("downstream rejected [REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts a custom environment key when its option name has whitespace", async () => {
|
||||
process.env.PAPERCLIP_HOME = "/tmp/test-drive-custom-env-redaction";
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
process.env.CUSTOM_TEST_DRIVE_KEY = "custom-secret";
|
||||
|
||||
await expect(testDriveCommand({
|
||||
apiKeyEnv: " CUSTOM_TEST_DRIVE_KEY ",
|
||||
browser: false,
|
||||
}, {
|
||||
run: async () => {
|
||||
throw new Error("downstream rejected custom-secret");
|
||||
},
|
||||
createApi: () => freshBootstrapApi().api,
|
||||
openBrowser: vi.fn(async () => true),
|
||||
})).rejects.toThrow("downstream rejected [REDACTED]");
|
||||
});
|
||||
|
||||
it("honors --no-browser after successful initialization", async () => {
|
||||
process.env.PAPERCLIP_HOME = "/tmp/test-drive-no-browser";
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
const api = {
|
||||
get: vi.fn(async <T>() => [company("existing", "Existing")] as T),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as TestDriveApi;
|
||||
const openBrowser = vi.fn(async () => true);
|
||||
|
||||
await testDriveCommand({ browser: false }, {
|
||||
run: async (options) => options.afterStart?.(server),
|
||||
createApi: () => api,
|
||||
openBrowser,
|
||||
});
|
||||
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
|
||||
import { writeManagedShim, flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
|
||||
import type { CommandRunner } from "../commands/install.js";
|
||||
import { compareVersions, detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js";
|
||||
|
||||
|
|
@ -36,6 +36,56 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("update command", () => {
|
||||
it.each(["npm", "git", "global-npm"] as const)("rejects %s updates on unsupported Node before any update work", async (source) => {
|
||||
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
|
||||
const payload = payloadPathFor(paths, "npm", "1.0.0");
|
||||
const entrypoint = createPayload(payload, "1.0.0");
|
||||
flipCurrentAtomic(payload, paths);
|
||||
const manifest: InstallManifest = { schemaVersion: 1, ...record(payload, "1.0.0"), source: source === "git" ? "git" : "npm", previous: [] };
|
||||
writeInstallManifestAtomic(manifest, paths);
|
||||
const runCommand = vi.fn<CommandRunner>();
|
||||
const backup = vi.fn();
|
||||
const restartActiveService = vi.fn();
|
||||
const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, "node")!;
|
||||
Object.defineProperty(process.versions, "node", { ...nodeVersion, value: "22.22.2" });
|
||||
try {
|
||||
await expect(updateCommand({ yes: true }, {
|
||||
paths,
|
||||
executablePath: source === "global-npm" ? path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js") : entrypoint,
|
||||
runCommand, backup, restartActiveService,
|
||||
})).rejects.toThrow("npx paperclipai@latest install --yes");
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(backup).not.toHaveBeenCalled();
|
||||
expect(restartActiveService).not.toHaveBeenCalled();
|
||||
expect(readInstallManifest(paths)).toEqual(manifest);
|
||||
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(payload));
|
||||
} finally {
|
||||
Object.defineProperty(process.versions, "node", nodeVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps update checks, dry runs, and rollback available on unsupported Node", async () => {
|
||||
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
|
||||
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); createPayload(oldPayload, "1.0.0");
|
||||
const payload = payloadPathFor(paths, "npm", "2.0.0"); const executablePath = createPayload(payload, "2.0.0");
|
||||
flipCurrentAtomic(payload, paths);
|
||||
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "2.0.0"), previous: [record(oldPayload, "1.0.0")] }, paths);
|
||||
const runCommand = vi.fn(async () => ({ stdout: '\"3.0.0\"\n', stderr: "" }));
|
||||
const restartActiveService = vi.fn(async () => false);
|
||||
const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, "node")!;
|
||||
Object.defineProperty(process.versions, "node", { ...nodeVersion, value: "22.22.2" });
|
||||
try {
|
||||
await updateCommand({ check: true }, { paths, executablePath, runCommand });
|
||||
await updateCommand({ dryRun: true }, { paths, executablePath, runCommand });
|
||||
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
|
||||
await updateCommand({ rollback: true }, { paths, executablePath, restartActiveService });
|
||||
expect(readInstallManifest(paths)?.version).toBe("1.0.0");
|
||||
expect(restartActiveService).toHaveBeenCalledWith("1.0.0");
|
||||
} finally {
|
||||
Object.defineProperty(process.versions, "node", nodeVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it("orders SemVer prerelease identifiers numerically", () => {
|
||||
expect(compareVersions("1.0.0-canary.10", "1.0.0-canary.2")).toBeGreaterThan(0);
|
||||
expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBeLessThan(0);
|
||||
|
|
@ -72,12 +122,16 @@ describe("update command", () => {
|
|||
fs.writeFileSync(path.join(newPayload, "node_modules", "paperclipai", "package.json"), JSON.stringify({ version: "0.3.1" }));
|
||||
flipCurrentAtomic(oldPayload, paths);
|
||||
writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: "master", sha: oldSha, payloadPath: oldPayload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths);
|
||||
writeManagedShim(paths);
|
||||
// Simulate a launcher generated before child-runtime PATH pinning existed.
|
||||
fs.writeFileSync(paths.shimPath, fs.readFileSync(paths.shimPath, "utf8").replace(/^export PATH=.*\n/m, ""));
|
||||
const backup = vi.fn(async () => undefined);
|
||||
const confirm = vi.fn(async () => true);
|
||||
const restartActiveService = vi.fn(async () => true);
|
||||
const runCommand = vi.fn(async (file: string) => file === "curl" ? { stdout: JSON.stringify({ sha: newSha }), stderr: "" } : { stdout: "0.3.1\n", stderr: "" });
|
||||
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, confirm, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining(`commit ${newSha.slice(0, 12)}`));
|
||||
expect(fs.readFileSync(paths.shimPath, "utf8")).toContain(`export PATH='${path.dirname(process.execPath)}'`);
|
||||
expect(backup).toHaveBeenCalledOnce();
|
||||
expect(restartActiveService).toHaveBeenCalledWith("0.3.1");
|
||||
expect(readInstallManifest(paths)?.sha).toBe(newSha);
|
||||
|
|
@ -134,6 +188,9 @@ describe("update command", () => {
|
|||
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
|
||||
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
|
||||
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
|
||||
writeManagedShim(paths);
|
||||
// Simulate a launcher generated before child-runtime PATH pinning existed.
|
||||
fs.writeFileSync(paths.shimPath, fs.readFileSync(paths.shimPath, "utf8").replace(/^export PATH=.*\n/m, ""));
|
||||
const backup = vi.fn(async () => undefined);
|
||||
const restartActiveService = vi.fn(async () => true);
|
||||
const runCommand = vi.fn(async (file: string, args: string[]) => {
|
||||
|
|
@ -142,6 +199,7 @@ describe("update command", () => {
|
|||
return { stdout: "2.0.0\n", stderr: "" };
|
||||
});
|
||||
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
|
||||
expect(fs.readFileSync(paths.shimPath, "utf8")).toContain(`export PATH='${path.dirname(process.execPath)}'`);
|
||||
expect(backup).toHaveBeenCalledOnce();
|
||||
expect(restartActiveService).toHaveBeenCalledWith("2.0.0");
|
||||
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { execFileSync } from "node:child_process";
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:net";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
|
|
@ -13,6 +15,8 @@ import {
|
|||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
closeRegisteredClients,
|
||||
ensurePostgresDatabase,
|
||||
executionWorkspaces,
|
||||
inspectMigrations,
|
||||
issueComments,
|
||||
|
|
@ -161,15 +165,12 @@ async function seedValidWorktreeSource(
|
|||
principalId: userId,
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Representative seed issue",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
issueNumber: 1,
|
||||
identifier: "SEED-1",
|
||||
});
|
||||
// This helper also seeds an intentionally older schema. Current Drizzle
|
||||
// insert builders include defaults for newly added columns absent there.
|
||||
await db.$client`
|
||||
insert into issues (id, company_id, title, status, priority, issue_number, identifier)
|
||||
values (${issueId}, ${companyId}, 'Representative seed issue', 'backlog', 'medium', 1, 'SEED-1')
|
||||
`;
|
||||
await db.$client.end({ timeout: 5 });
|
||||
return { companyId, issueId };
|
||||
}
|
||||
|
|
@ -1644,17 +1645,32 @@ describe("worktree helpers", () => {
|
|||
const sourceEnvPath = path.join(sourceConfigDir, ".env");
|
||||
const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key");
|
||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
|
||||
onTestFinished(() => sourceDb.cleanup());
|
||||
|
||||
await seedValidWorktreeSource(sourceDb.connectionString);
|
||||
const sourceCluster = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
|
||||
const sourceUrl = new URL(sourceCluster.connectionString);
|
||||
sourceUrl.pathname = "/lagging_source";
|
||||
const sourceDb = { connectionString: sourceUrl.toString() };
|
||||
onTestFinished(async () => {
|
||||
await closeRegisteredClients(sourceDb.connectionString);
|
||||
await sourceCluster.cleanup();
|
||||
});
|
||||
await ensurePostgresDatabase(sourceCluster.connectionString, "lagging_source");
|
||||
// A lagging source must also have the prior schema. Deleting only the
|
||||
// newest receipt from a fully migrated schema relied on that particular
|
||||
// migration being idempotent and breaks when the new migration creates a
|
||||
// table. Build the actual all-but-last schema before shuffling its history.
|
||||
const migrationsRoot = new URL("../../../packages/db/src/migrations/", import.meta.url);
|
||||
const journal = JSON.parse(fs.readFileSync(new URL("meta/_journal.json", migrationsRoot), "utf8"));
|
||||
const priorEntries = journal.entries.slice(0, -1);
|
||||
const priorMigrations = path.join(tempRoot, "prior-migrations");
|
||||
fs.mkdirSync(path.join(priorMigrations, "meta"), { recursive: true });
|
||||
fs.writeFileSync(path.join(priorMigrations, "meta", "_journal.json"), JSON.stringify({ ...journal, entries: priorEntries }));
|
||||
for (const entry of priorEntries) {
|
||||
fs.copyFileSync(new URL(`${entry.tag}.sql`, migrationsRoot), path.join(priorMigrations, `${entry.tag}.sql`));
|
||||
}
|
||||
const sourceDbClient = createDb(sourceDb.connectionString);
|
||||
await migrate(drizzle(sourceDbClient.$client), { migrationsFolder: priorMigrations });
|
||||
await seedValidWorktreeSource(sourceDb.connectionString);
|
||||
await sourceDbClient.$client.unsafe(`
|
||||
DELETE FROM "drizzle"."__drizzle_migrations"
|
||||
WHERE "id" = (
|
||||
SELECT max("id") FROM "drizzle"."__drizzle_migrations"
|
||||
);
|
||||
|
||||
WITH pair AS (
|
||||
SELECT
|
||||
array_agg("id" ORDER BY "id" DESC) AS ids,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { Command } from "commander";
|
||||
import { emailSendSchema } from "@paperclipai/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
resolveCommandContext,
|
||||
printOutput,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
export function registerEmailCommands(program: Command) {
|
||||
const email = program
|
||||
.command("email")
|
||||
.description(
|
||||
"Explicitly send and inspect task-bound AgentMail conversations",
|
||||
);
|
||||
addCommonClientOptions(email.command("inboxes"), {
|
||||
includeCompany: true,
|
||||
}).action(async (opts: BaseClientOptions) => {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
printOutput(
|
||||
await ctx.api.get(`/api/companies/${ctx.companyId}/email/inboxes`),
|
||||
{ json: true },
|
||||
);
|
||||
});
|
||||
for (const verb of ["send", "reply"] as const) {
|
||||
addCommonClientOptions(
|
||||
email
|
||||
.command(verb)
|
||||
.requiredOption(
|
||||
"--file <path>",
|
||||
"JSON request file, including a stable idempotencyKey",
|
||||
),
|
||||
{ includeCompany: true },
|
||||
).action(async (opts: BaseClientOptions & { file: string }) => {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const input = emailSendSchema.parse(
|
||||
JSON.parse(await readFile(opts.file, "utf8")),
|
||||
);
|
||||
if ((verb === "reply") !== Boolean(input.conversationId))
|
||||
throw new Error(
|
||||
`${verb} requires ${verb === "reply" ? "an existing conversation" : "a parent task and a new conversation"}`,
|
||||
);
|
||||
printOutput(
|
||||
await ctx.api.post(`/api/companies/${ctx.companyId}/email/send`, input),
|
||||
{ json: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
addCommonClientOptions(
|
||||
email.command("thread").argument("<issueId>", "Email task ID"),
|
||||
{ includeCompany: true },
|
||||
).action(async (issueId: string, opts: BaseClientOptions) => {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
printOutput(
|
||||
await ctx.api.get(
|
||||
`/api/companies/${ctx.companyId}/email/tasks/${encodeURIComponent(issueId)}`,
|
||||
),
|
||||
{ json: true },
|
||||
);
|
||||
});
|
||||
addCommonClientOptions(
|
||||
email
|
||||
.command("delivery")
|
||||
.argument("<publicationId>", "Publication ID returned by send"),
|
||||
{ includeCompany: true },
|
||||
).action(async (publicationId: string, opts: BaseClientOptions) => {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
printOutput(
|
||||
await ctx.api.get(
|
||||
`/api/companies/${ctx.companyId}/email/deliveries/${encodeURIComponent(publicationId)}`,
|
||||
),
|
||||
{ json: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ interface IssueUpdateOptions extends BaseClientOptions {
|
|||
|
||||
interface IssueCommentOptions extends BaseClientOptions {
|
||||
body: string;
|
||||
attachmentId?: string[];
|
||||
reopen?: boolean;
|
||||
resume?: boolean;
|
||||
}
|
||||
|
|
@ -361,6 +362,10 @@ export function registerIssueCommands(program: Command): void {
|
|||
.description("Add comment to issue")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.requiredOption("--body <text>", "Comment body")
|
||||
.option(
|
||||
"--attachment-id <id...>",
|
||||
"Bind uploaded issue attachments to this comment",
|
||||
)
|
||||
.option("--reopen", "Reopen if issue is done/cancelled")
|
||||
.option("--resume", "Request explicit follow-up and wake the assignee when resumable")
|
||||
.action(async (issueId: string, opts: IssueCommentOptions) => {
|
||||
|
|
@ -368,6 +373,7 @@ export function registerIssueCommands(program: Command): void {
|
|||
const ctx = resolveCommandContext(opts);
|
||||
const payload = addIssueCommentSchema.parse({
|
||||
body: opts.body,
|
||||
attachmentIds: opts.attachmentId,
|
||||
reopen: opts.reopen,
|
||||
resume: opts.resume,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
export type GitWorkspaceInfo = {
|
||||
root: string;
|
||||
commonDir: string;
|
||||
gitDir: string;
|
||||
hooksPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the repository metadata Git exposes for both primary checkouts and
|
||||
* linked worktrees. Returns null outside a Git working tree.
|
||||
*/
|
||||
export function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||
try {
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return {
|
||||
root: path.resolve(root),
|
||||
commonDir: path.resolve(root, commonDirRaw),
|
||||
gitDir: path.resolve(root, gitDirRaw),
|
||||
hooksPath: path.resolve(root, hooksPathRaw),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLinkedGitWorktree(cwd: string): boolean {
|
||||
const workspace = detectGitWorkspaceInfo(cwd);
|
||||
return Boolean(workspace && workspace.gitDir !== workspace.commonDir);
|
||||
}
|
||||
|
|
@ -84,9 +84,9 @@ export function resolveGitInstallWorkspacePackages(checkoutPath: string): Releas
|
|||
return ordered;
|
||||
}
|
||||
|
||||
function assertSupportedNodeVersion(): void {
|
||||
export function assertSupportedNodeVersion(): void {
|
||||
if (!isSupportedNodeVersion(process.versions.node)) {
|
||||
throw new Error(`Managed installs require Node.js ${MINIMUM_NODE_VERSION} or newer (found ${process.version}).`);
|
||||
throw new Error(`Installing or updating Paperclip requires Node.js ${MINIMUM_NODE_VERSION} or newer (found ${process.version} at ${process.execPath}). Put a supported Node bin directory first on PATH and run 'npx paperclipai@latest install --yes' to re-pin an existing managed install.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,26 +21,37 @@ import { removeRuntimeInfoForPid, writeRuntimeInfo } from "../runtime-info.js";
|
|||
import { printUpdateNotice } from "../update-notice.js";
|
||||
import { ensureWorktreeSeeded } from "./worktree.js";
|
||||
|
||||
interface RunOptions {
|
||||
export interface RunOptions {
|
||||
config?: string;
|
||||
instance?: string;
|
||||
repair?: boolean;
|
||||
yes?: boolean;
|
||||
bind?: "loopback" | "lan" | "tailnet";
|
||||
force?: boolean;
|
||||
/** Internal lifecycle option used by foreground-only commands. */
|
||||
installService?: boolean;
|
||||
/** Internal lifecycle option for isolated instances that cannot collide with a managed service. */
|
||||
skipServiceManagerCheck?: boolean;
|
||||
/** Internal label override for commands that reuse the foreground run path. */
|
||||
introLabel?: string;
|
||||
/** Runs after the server is listening and all normal post-start initialization has completed. */
|
||||
afterStart?: (server: StartedServer) => Promise<void>;
|
||||
}
|
||||
|
||||
interface StartedServer {
|
||||
export interface StartedServer {
|
||||
apiUrl: string;
|
||||
databaseUrl: string;
|
||||
host: string;
|
||||
listenPort: number;
|
||||
shutdown?: (signal?: "SIGINT" | "SIGTERM") => Promise<void>;
|
||||
}
|
||||
|
||||
export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
const instanceId = resolvePaperclipInstanceId(opts.instance);
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
await assertForegroundRunAllowed(instanceId, opts.force);
|
||||
if (!opts.skipServiceManagerCheck) {
|
||||
await assertForegroundRunAllowed(instanceId, opts.force);
|
||||
}
|
||||
|
||||
const homeDir = resolvePaperclipHomeDir();
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
|
|
@ -53,20 +64,26 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
|||
loadPaperclipEnvFile(configPath);
|
||||
await printUpdateNotice(configPath);
|
||||
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
|
||||
p.intro(pc.bgCyan(pc.black(` ${opts.introLabel ?? "paperclipai run"} `)));
|
||||
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
||||
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
|
||||
p.log.message(pc.dim(`Config: ${configPath}`));
|
||||
|
||||
if (!configExists(configPath)) {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
if ((!process.stdin.isTTY || !process.stdout.isTTY) && !opts.yes) {
|
||||
p.log.error("No config found and terminal is non-interactive.");
|
||||
p.log.message(`Run ${pc.cyan("paperclipai onboard")} once, then retry ${pc.cyan("paperclipai run")}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
p.log.step("No config found. Starting onboarding...");
|
||||
await onboard({ config: configPath, invokedByRun: true, bind: opts.bind });
|
||||
await onboard({
|
||||
config: configPath,
|
||||
invokedByRun: true,
|
||||
bind: opts.bind,
|
||||
yes: opts.yes,
|
||||
installService: opts.installService,
|
||||
});
|
||||
}
|
||||
|
||||
const seedResult = await ensureWorktreeSeeded({ config: configPath });
|
||||
|
|
@ -113,6 +130,15 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
|||
baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer),
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.afterStart) {
|
||||
try {
|
||||
await opts.afterStart(startedServer);
|
||||
} catch (error) {
|
||||
await startedServer.shutdown?.("SIGTERM");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBootstrapInviteBaseUrl(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,507 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer } from "node:net";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { Option, type Command } from "commander";
|
||||
import type { Agent, Company, InstanceExperimentalSettings } from "@paperclipai/shared";
|
||||
import { PaperclipApiClient } from "../client/http.js";
|
||||
import { openUrl } from "../client/board-auth.js";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultConfigPath,
|
||||
resolveDefaultContextPath,
|
||||
} from "../config/home.js";
|
||||
import { readConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { runCommand, type StartedServer } from "./run.js";
|
||||
import { isLinkedGitWorktree } from "./git-workspace.js";
|
||||
|
||||
export const TEST_DRIVE_HARNESSES = ["claude", "codex", "opencode"] as const;
|
||||
export type TestDriveHarness = (typeof TEST_DRIVE_HARNESSES)[number];
|
||||
|
||||
export interface TestDriveOptions {
|
||||
dataDir?: string;
|
||||
companyName?: string;
|
||||
agentName?: string;
|
||||
harness?: TestDriveHarness;
|
||||
model?: string;
|
||||
apiKeyEnv?: string;
|
||||
apiKey?: string;
|
||||
browser?: boolean;
|
||||
}
|
||||
|
||||
export type TestDriveApi = Pick<PaperclipApiClient, "get" | "post" | "patch" | "delete">;
|
||||
|
||||
type HarnessDefinition = {
|
||||
adapterType: "claude_local" | "codex_local" | "opencode_local";
|
||||
credentialTarget: "ANTHROPIC_API_KEY" | "OPENAI_API_KEY" | "OPENROUTER_API_KEY";
|
||||
credentialName: string;
|
||||
};
|
||||
|
||||
export type ResolvedTestDriveBootstrap = HarnessDefinition & {
|
||||
companyName: string;
|
||||
agentName: string;
|
||||
model?: string;
|
||||
credential: string;
|
||||
credentialSource: string;
|
||||
};
|
||||
|
||||
export type TestDriveBootstrapResult = {
|
||||
reused: boolean;
|
||||
company: Company;
|
||||
agent: Agent | null;
|
||||
};
|
||||
|
||||
export interface TestDriveDependencies {
|
||||
run: typeof runCommand;
|
||||
createApi: (apiBase: string) => TestDriveApi;
|
||||
openBrowser: (url: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const HARNESS_DEFINITIONS: Record<TestDriveHarness, HarnessDefinition> = {
|
||||
claude: {
|
||||
adapterType: "claude_local",
|
||||
credentialTarget: "ANTHROPIC_API_KEY",
|
||||
credentialName: "Anthropic API Key",
|
||||
},
|
||||
codex: {
|
||||
adapterType: "codex_local",
|
||||
credentialTarget: "OPENAI_API_KEY",
|
||||
credentialName: "OpenAI API Key",
|
||||
},
|
||||
opencode: {
|
||||
adapterType: "opencode_local",
|
||||
credentialTarget: "OPENROUTER_API_KEY",
|
||||
credentialName: "OpenRouter API Key",
|
||||
},
|
||||
};
|
||||
|
||||
const NON_PAPERCLIP_ISOLATED_ENV_KEYS = [
|
||||
"DATABASE_URL",
|
||||
"DATABASE_MIGRATION_URL",
|
||||
"HOST",
|
||||
"PORT",
|
||||
"SERVE_UI",
|
||||
"BETTER_AUTH_URL",
|
||||
"BETTER_AUTH_BASE_URL",
|
||||
] as const;
|
||||
|
||||
function requiredApiResult<T>(value: T | null, action: string): T {
|
||||
if (value === null) {
|
||||
throw new Error(`Paperclip returned no result while ${action}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message || error.name;
|
||||
if (typeof error === "string") return error;
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function redactTestDriveText(text: string, credentials: Array<string | undefined>): string {
|
||||
let redacted = text;
|
||||
for (const credential of credentials) {
|
||||
if (!credential) continue;
|
||||
redacted = redacted.replaceAll(credential, "[REDACTED]");
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function redactTestDriveArgv(
|
||||
apiKey: string | undefined,
|
||||
argv: string[] = process.argv,
|
||||
): void {
|
||||
if (!apiKey) return;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === "--api-key" && argv[index + 1] === apiKey) {
|
||||
argv[index + 1] = "[REDACTED]";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (argv[index] === `--api-key=${apiKey}`) {
|
||||
argv[index] = "--api-key=[REDACTED]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTestDriveDataDir(dataDir?: string): string {
|
||||
const explicit = dataDir?.trim();
|
||||
if (explicit) {
|
||||
return path.resolve(expandHomePrefix(explicit));
|
||||
}
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-"));
|
||||
}
|
||||
|
||||
async function loopbackPortAvailable(port: number): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveTestDriveServerPort(preferredPort = 3100): Promise<number> {
|
||||
for (let port = preferredPort; port <= 65_535; port += 1) {
|
||||
if (await loopbackPortAvailable(port)) return port;
|
||||
}
|
||||
throw new Error(`No available loopback port found at or above ${preferredPort}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish isolation before the CLI's normal config and .env loading hook.
|
||||
* The selected credential source is preserved in case its name happens to use
|
||||
* a PAPERCLIP_ prefix; all other Paperclip routing/configuration is discarded.
|
||||
*/
|
||||
export async function prepareTestDriveEnvironment(
|
||||
options: Pick<TestDriveOptions, "dataDir" | "apiKeyEnv">,
|
||||
cwd = process.cwd(),
|
||||
): Promise<{ dataDir: string; linkedWorktree: boolean }> {
|
||||
const sourceEnvName = options.apiKeyEnv?.trim();
|
||||
const preservedCredential = sourceEnvName ? process.env[sourceEnvName] : undefined;
|
||||
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith("PAPERCLIP_")) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
for (const key of NON_PAPERCLIP_ISOLATED_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
if (sourceEnvName && preservedCredential !== undefined) {
|
||||
process.env[sourceEnvName] = preservedCredential;
|
||||
}
|
||||
|
||||
const dataDir = resolveTestDriveDataDir(options.dataDir);
|
||||
const linkedWorktree = isLinkedGitWorktree(cwd);
|
||||
process.env.PAPERCLIP_HOME = dataDir;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
process.env.PAPERCLIP_CONFIG = resolveDefaultConfigPath("default");
|
||||
process.env.PAPERCLIP_CONTEXT = resolveDefaultContextPath();
|
||||
process.env.PAPERCLIP_IN_WORKTREE = linkedWorktree ? "true" : "false";
|
||||
process.env.PAPERCLIP_OPEN_ON_LISTEN = "false";
|
||||
process.env.PAPERCLIP_DISABLE_CWD_ENV_FILE = "true";
|
||||
process.env.PAPERCLIP_DEPLOYMENT_MODE = "local_trusted";
|
||||
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
|
||||
process.env.PAPERCLIP_BIND = "loopback";
|
||||
process.env.HOST = "127.0.0.1";
|
||||
process.env.PORT = String(await resolveTestDriveServerPort());
|
||||
|
||||
return { dataDir, linkedWorktree };
|
||||
}
|
||||
|
||||
export function assertTestDriveDatabaseIsolation(
|
||||
configPath?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
readConfigFile: (path?: string) => PaperclipConfig | null = readConfig,
|
||||
): void {
|
||||
if (env.DATABASE_URL?.trim() || env.DATABASE_MIGRATION_URL?.trim()) {
|
||||
throw new Error(
|
||||
"test-drive requires its isolated embedded database. Remove DATABASE_URL and " +
|
||||
"DATABASE_MIGRATION_URL from the selected data directory's .env, or choose a fresh --data-dir.",
|
||||
);
|
||||
}
|
||||
|
||||
const config = readConfigFile(configPath);
|
||||
if (config?.database.mode === "postgres") {
|
||||
throw new Error(
|
||||
"test-drive cannot reuse a data directory configured for an external PostgreSQL database. " +
|
||||
"Choose a fresh data directory or change database.mode to embedded-postgres.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTestDriveBootstrap(
|
||||
options: TestDriveOptions,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ResolvedTestDriveBootstrap {
|
||||
if (options.apiKey !== undefined && options.apiKeyEnv !== undefined) {
|
||||
throw new Error("--api-key and --api-key-env are mutually exclusive.");
|
||||
}
|
||||
|
||||
const harness = options.harness ?? "claude";
|
||||
const definition = HARNESS_DEFINITIONS[harness];
|
||||
if (!definition) {
|
||||
throw new Error(`Unsupported test-drive harness: ${String(harness)}.`);
|
||||
}
|
||||
|
||||
const companyName = (options.companyName ?? "Test Company").trim();
|
||||
const agentName = (options.agentName ?? "CEO").trim();
|
||||
if (!companyName) throw new Error("--company-name cannot be empty.");
|
||||
if (!agentName) throw new Error("--agent-name cannot be empty.");
|
||||
|
||||
const model = options.model;
|
||||
if (model !== undefined && (!model || model.trim() !== model)) {
|
||||
throw new Error("--model cannot be empty or have surrounding whitespace.");
|
||||
}
|
||||
if (
|
||||
harness === "opencode" &&
|
||||
(!model || !/^openrouter\/[^/\s]+(?:\/[^/\s]+)*$/.test(model))
|
||||
) {
|
||||
throw new Error(
|
||||
"OpenCode test drives require --model openrouter/<model>, with no empty path segments.",
|
||||
);
|
||||
}
|
||||
|
||||
const sourceEnvName = options.apiKeyEnv?.trim() || definition.credentialTarget;
|
||||
if (options.apiKeyEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sourceEnvName)) {
|
||||
throw new Error("--api-key-env must name a valid environment variable.");
|
||||
}
|
||||
const credential = options.apiKey ?? env[sourceEnvName];
|
||||
if (!credential || credential.trim().length === 0) {
|
||||
throw new Error(
|
||||
`No credential found. Set ${sourceEnvName}, pass --api-key-env <variable>, or pass --api-key <value>.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...definition,
|
||||
companyName,
|
||||
agentName,
|
||||
...(model ? { model } : {}),
|
||||
credential,
|
||||
credentialSource: options.apiKey !== undefined ? "--api-key" : sourceEnvName,
|
||||
};
|
||||
}
|
||||
|
||||
function worktreeExecutionArmed(
|
||||
settings: InstanceExperimentalSettings,
|
||||
instanceId: string,
|
||||
): boolean {
|
||||
return settings.enableWorktreeRunExecution === true
|
||||
&& Boolean(settings.worktreeRunExecutionActivatedAt)
|
||||
&& settings.worktreeRunExecutionActivationInstanceId === instanceId;
|
||||
}
|
||||
|
||||
export async function reconcileTestDriveWorktreeExecution(
|
||||
api: TestDriveApi,
|
||||
instanceId: string,
|
||||
): Promise<void> {
|
||||
const current = requiredApiResult(
|
||||
await api.get<InstanceExperimentalSettings>("/api/instance/settings/experimental"),
|
||||
"reading experimental settings",
|
||||
);
|
||||
|
||||
if (!current.enableWorktreeRunExecution) {
|
||||
await api.patch<InstanceExperimentalSettings>("/api/instance/settings/experimental", {
|
||||
enableWorktreeRunExecution: true,
|
||||
});
|
||||
} else if (!worktreeExecutionArmed(current, instanceId)) {
|
||||
await api.patch<InstanceExperimentalSettings>("/api/instance/settings/experimental", {
|
||||
enableWorktreeRunExecution: false,
|
||||
});
|
||||
await api.patch<InstanceExperimentalSettings>("/api/instance/settings/experimental", {
|
||||
enableWorktreeRunExecution: true,
|
||||
});
|
||||
}
|
||||
|
||||
const verified = requiredApiResult(
|
||||
await api.get<InstanceExperimentalSettings>("/api/instance/settings/experimental"),
|
||||
"verifying experimental settings",
|
||||
);
|
||||
if (!worktreeExecutionArmed(verified, instanceId)) {
|
||||
throw new Error(
|
||||
`Could not arm “Run tasks in this worktree” for Paperclip instance ${instanceId}. ` +
|
||||
"Check that PAPERCLIP_IN_WORKTREE=true and retry the command.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapTestDrive(input: {
|
||||
api: TestDriveApi;
|
||||
options: TestDriveOptions;
|
||||
linkedWorktree: boolean;
|
||||
instanceId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<TestDriveBootstrapResult> {
|
||||
const companies = requiredApiResult(
|
||||
await input.api.get<Company[]>("/api/companies"),
|
||||
"reading companies",
|
||||
);
|
||||
const existingCompany = companies[0];
|
||||
if (existingCompany) {
|
||||
if (input.linkedWorktree) {
|
||||
await reconcileTestDriveWorktreeExecution(input.api, input.instanceId);
|
||||
}
|
||||
return { reused: true, company: existingCompany, agent: null };
|
||||
}
|
||||
|
||||
// Resolve every bootstrap input before the first mutation. In particular,
|
||||
// OpenCode model validation and credential lookup happen before company
|
||||
// creation so an invalid invocation leaves the database untouched.
|
||||
const resolved = resolveTestDriveBootstrap(input.options, input.env);
|
||||
let company: Company | null = null;
|
||||
try {
|
||||
company = requiredApiResult(
|
||||
await input.api.post<Company>("/api/companies", { name: resolved.companyName }),
|
||||
"creating the test company",
|
||||
);
|
||||
await input.api.post(`/api/companies/${company.id}/user-secret-definitions`, {
|
||||
key: resolved.credentialTarget,
|
||||
name: resolved.credentialName,
|
||||
});
|
||||
await input.api.post(`/api/companies/${company.id}/me/user-secrets`, {
|
||||
definitionKey: resolved.credentialTarget,
|
||||
value: resolved.credential,
|
||||
});
|
||||
|
||||
const adapterConfig: Record<string, unknown> = {
|
||||
env: {
|
||||
[resolved.credentialTarget]: {
|
||||
type: "user_secret_ref",
|
||||
key: resolved.credentialTarget,
|
||||
version: "latest",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (resolved.model) adapterConfig.model = resolved.model;
|
||||
|
||||
const agent = requiredApiResult(
|
||||
await input.api.post<Agent>(`/api/companies/${company.id}/agents`, {
|
||||
name: resolved.agentName,
|
||||
role: "ceo",
|
||||
adapterType: resolved.adapterType,
|
||||
adapterConfig,
|
||||
}),
|
||||
"creating the CEO agent",
|
||||
);
|
||||
|
||||
if (input.linkedWorktree) {
|
||||
await reconcileTestDriveWorktreeExecution(input.api, input.instanceId);
|
||||
}
|
||||
return { reused: false, company, agent };
|
||||
} catch (error) {
|
||||
if (company) {
|
||||
try {
|
||||
await input.api.delete(`/api/companies/${company.id}`);
|
||||
} catch (cleanupError) {
|
||||
throw new Error(
|
||||
`${errorMessage(error)} Cleanup also failed for newly-created company ${company.id}: ${errorMessage(cleanupError)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardUrl(server: StartedServer): string {
|
||||
return server.apiUrl.replace(/\/api\/?$/, "");
|
||||
}
|
||||
|
||||
export async function testDriveCommand(
|
||||
options: TestDriveOptions,
|
||||
dependencies: TestDriveDependencies = {
|
||||
run: runCommand,
|
||||
createApi: (apiBase) => new PaperclipApiClient({ apiBase }),
|
||||
openBrowser: openUrl,
|
||||
},
|
||||
): Promise<void> {
|
||||
// Commander has already copied the value into options. Remove it from the
|
||||
// JavaScript argv view before logging, telemetry, diagnostics, or startup.
|
||||
redactTestDriveArgv(options.apiKey);
|
||||
const dataDir = path.resolve(process.env.PAPERCLIP_HOME ?? resolveTestDriveDataDir(options.dataDir));
|
||||
const linkedWorktree = process.env.PAPERCLIP_IN_WORKTREE === "true";
|
||||
const instanceId = process.env.PAPERCLIP_INSTANCE_ID ?? "default";
|
||||
// Resolve environment-backed credentials against the CLI environment as it
|
||||
// exists before server startup. In-process server initialization must not
|
||||
// change which credential the post-listen bootstrap observes.
|
||||
const bootstrapEnv = { ...process.env };
|
||||
const possibleCredentials = [
|
||||
options.apiKey,
|
||||
options.apiKeyEnv ? bootstrapEnv[options.apiKeyEnv.trim()] : undefined,
|
||||
bootstrapEnv[HARNESS_DEFINITIONS[options.harness ?? "claude"].credentialTarget],
|
||||
];
|
||||
|
||||
p.log.message(pc.dim(`Data directory: ${dataDir}`));
|
||||
p.log.message(pc.dim("The data directory is retained when Paperclip exits."));
|
||||
if (options.apiKey !== undefined) {
|
||||
p.log.warn("A key passed with --api-key may be visible in process arguments and shell history.");
|
||||
}
|
||||
|
||||
try {
|
||||
await dependencies.run({
|
||||
repair: true,
|
||||
yes: true,
|
||||
bind: "loopback",
|
||||
installService: false,
|
||||
// Auto-created directories are private to this process. Explicitly reused
|
||||
// directories retain the normal guard against an already-managed instance.
|
||||
skipServiceManagerCheck: !options.dataDir?.trim(),
|
||||
introLabel: "paperclipai test-drive",
|
||||
afterStart: async (server) => {
|
||||
const api = dependencies.createApi(server.apiUrl);
|
||||
const result = await bootstrapTestDrive({
|
||||
api,
|
||||
options,
|
||||
linkedWorktree,
|
||||
instanceId,
|
||||
env: bootstrapEnv,
|
||||
});
|
||||
if (result.reused) {
|
||||
p.log.message(
|
||||
`Using existing data for ${pc.cyan(result.company.name)}; bootstrap flags were ignored.`,
|
||||
);
|
||||
} else {
|
||||
p.log.success(
|
||||
`Created ${pc.cyan(result.company.name)} with agent ${pc.cyan(result.agent?.name ?? "CEO")}.`,
|
||||
);
|
||||
}
|
||||
if (linkedWorktree) {
|
||||
p.log.success("Run tasks in this worktree is enabled for this instance.");
|
||||
}
|
||||
|
||||
const url = dashboardUrl(server);
|
||||
if (options.browser === false) {
|
||||
p.log.success(`Paperclip is ready at ${pc.cyan(url)}.`);
|
||||
return;
|
||||
}
|
||||
const opened = await dependencies.openBrowser(url);
|
||||
if (opened) {
|
||||
p.log.success(`Paperclip is ready and opened at ${pc.cyan(url)}.`);
|
||||
} else {
|
||||
p.log.warn(`Paperclip is ready, but the browser could not be opened. Visit ${url}.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(redactTestDriveText(errorMessage(error), possibleCredentials), { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTestDriveCommand(program: Command): void {
|
||||
program
|
||||
.command("test-drive")
|
||||
.description("Start an isolated, initialized Paperclip instance for manual testing")
|
||||
.option("-d, --data-dir <path>", "Paperclip data directory to create or reuse")
|
||||
.option("--company-name <name>", "Initial company name", "Test Company")
|
||||
.option("--agent-name <name>", "Initial CEO agent name", "CEO")
|
||||
.addOption(
|
||||
new Option("--harness <harness>", "Initial agent harness")
|
||||
.choices(TEST_DRIVE_HARNESSES)
|
||||
.default("claude"),
|
||||
)
|
||||
.option("--model <model-id>", "Initial agent model")
|
||||
.addOption(
|
||||
new Option("--api-key-env <variable>", "Read the provider key from an environment variable")
|
||||
.conflicts("apiKey"),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--api-key <value>", "Provider API key")
|
||||
.conflicts("apiKeyEnv"),
|
||||
)
|
||||
.option("--no-browser", "Do not open the initialized instance in a browser")
|
||||
.action(async (options: TestDriveOptions) => {
|
||||
await testDriveCommand(options);
|
||||
});
|
||||
}
|
||||
|
|
@ -5,9 +5,9 @@ import { execFile } from "node:child_process";
|
|||
import { promisify } from "node:util";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js";
|
||||
import { assertManagedShimWritable, writeManagedShim, buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js";
|
||||
import { dbBackupCommand } from "./db-backup.js";
|
||||
import { installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js";
|
||||
import { assertSupportedNodeVersion, installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js";
|
||||
import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js";
|
||||
import { resolveConfigPath } from "../config/store.js";
|
||||
import { detectServiceManager } from "../services/service-manager.js";
|
||||
|
|
@ -180,6 +180,7 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
|
|||
}
|
||||
if (mode === "npx") { emit(options, { mode, action: "install" }, "This is an ephemeral npx install. Run `paperclipai install`, then use `paperclipai update` from the managed shim."); return; }
|
||||
if (mode === "source" || mode === "unknown") { emit(options, { mode, action: "manual" }, "This appears to be a source checkout. Update it with `git pull` followed by `pnpm install`; Paperclip will not mutate the repository."); return; }
|
||||
if (!options.check && !options.dryRun) assertSupportedNodeVersion();
|
||||
const request = resolveUpdateRequest(mode === "managed" ? manifest : null, options);
|
||||
if (mode === "managed" && manifest?.source === "git") {
|
||||
if (!manifest.repo || !manifest.ref || !manifest.sha) throw new Error("Managed git install metadata is incomplete.");
|
||||
|
|
@ -193,7 +194,9 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
|
|||
}
|
||||
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})), overrides.hasInstanceData);
|
||||
const installed = await withInstallStoreLock(async () => {
|
||||
assertManagedShimWritable(paths);
|
||||
const payload = await installGitPayload(manifest.repo!, targetSha, runCommand, paths);
|
||||
writeManagedShim(paths);
|
||||
const record: InstallRecord = { source: "git", version: payload.version, channel: "pinned", repo: manifest.repo, ref: manifest.ref, sha: targetSha, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() };
|
||||
const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths);
|
||||
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
|
||||
|
|
@ -247,7 +250,9 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
|
|||
if (options.dryRun) { emit(options, { mode, currentVersion, targetVersion, action: comparison < 0 ? "downgrade" : "update", backup: options.backup !== false, dryRun: true }, `Would ${comparison < 0 ? "downgrade" : "update"} paperclipai ${currentVersion} → ${targetVersion}${options.backup === false ? " without a backup" : " after a database backup"}.`); return; }
|
||||
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})), overrides.hasInstanceData);
|
||||
const installed = await withInstallStoreLock(async () => {
|
||||
assertManagedShimWritable(paths);
|
||||
const payload = await installNpmPayload(targetVersion, runCommand, paths);
|
||||
writeManagedShim(paths);
|
||||
const record: InstallRecord = { source: "npm", version: targetVersion, channel: request.channel, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() };
|
||||
const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths);
|
||||
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ import {
|
|||
type PlannedIssueDocumentMerge,
|
||||
type PlannedIssueInsert,
|
||||
} from "./worktree-merge-history-lib.js";
|
||||
import { detectGitWorkspaceInfo } from "./git-workspace.js";
|
||||
|
||||
type WorktreeInitOptions = {
|
||||
name?: string;
|
||||
|
|
@ -204,13 +205,6 @@ type EmbeddedPostgresHandle = {
|
|||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type GitWorkspaceInfo = {
|
||||
root: string;
|
||||
commonDir: string;
|
||||
gitDir: string;
|
||||
hooksPath: string;
|
||||
};
|
||||
|
||||
type CopiedGitHooksResult = {
|
||||
sourceHooksPath: string;
|
||||
targetHooksPath: string;
|
||||
|
|
@ -717,39 +711,6 @@ function resolveRepairWorktreeDirName(branchName: string): string {
|
|||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||
try {
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return {
|
||||
root: path.resolve(root),
|
||||
commonDir: path.resolve(root, commonDirRaw),
|
||||
gitDir: path.resolve(root, gitDirRaw),
|
||||
hooksPath: path.resolve(root, hooksPathRaw),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirectoryContents(sourceDir: string, targetDir: string): boolean {
|
||||
if (!existsSync(sourceDir)) return false;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { registerEmailCommands } from "./commands/client/email.js";
|
||||
import { Command } from "commander";
|
||||
import { warnIfUnsupportedNodeVersion } from "@paperclipai/shared/node-version";
|
||||
import { onboard } from "./commands/onboard.js";
|
||||
|
|
@ -50,6 +51,13 @@ import { uninstallCommand } from "./commands/uninstall.js";
|
|||
import { updateCommand } from "./commands/update.js";
|
||||
import { registerServiceCommands } from "./commands/service.js";
|
||||
import { registerConnectionIntentCommands } from "./commands/client/connections.js";
|
||||
import {
|
||||
assertTestDriveDatabaseIsolation,
|
||||
prepareTestDriveEnvironment,
|
||||
redactTestDriveArgv,
|
||||
registerTestDriveCommand,
|
||||
type TestDriveOptions,
|
||||
} from "./commands/test-drive.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
|
|
@ -92,17 +100,31 @@ program
|
|||
.option("--no-backup", "Skip the pre-update database backup")
|
||||
.action(updateCommand);
|
||||
|
||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
|
||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||
const options = actionCommand.optsWithGlobals() as DataDirOptionLike & TestDriveOptions;
|
||||
let dataDirOptions: DataDirOptionLike = options;
|
||||
if (actionCommand.name() === "test-drive") {
|
||||
redactTestDriveArgv(options.apiKey);
|
||||
const prepared = await prepareTestDriveEnvironment({
|
||||
dataDir: options.dataDir,
|
||||
apiKeyEnv: options.apiKeyEnv,
|
||||
});
|
||||
dataDirOptions = { ...options, dataDir: prepared.dataDir };
|
||||
}
|
||||
const optionNames = new Set(actionCommand.options.map((option) => option.attributeName()));
|
||||
applyDataDirOverride(options, {
|
||||
applyDataDirOverride(dataDirOptions, {
|
||||
hasConfigOption: optionNames.has("config"),
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
if (actionCommand.name() === "test-drive") {
|
||||
assertTestDriveDatabaseIsolation(options.config);
|
||||
}
|
||||
initTelemetryFromConfigFile(options.config);
|
||||
});
|
||||
|
||||
registerTestDriveCommand(program);
|
||||
|
||||
program
|
||||
.command("onboard")
|
||||
.description("Interactive first-run setup wizard")
|
||||
|
|
@ -212,6 +234,7 @@ heartbeat
|
|||
registerContextCommands(program);
|
||||
registerConnectCommand(program);
|
||||
registerConnectionIntentCommands(program);
|
||||
registerEmailCommands(program);
|
||||
registerCompanyCommands(program);
|
||||
registerIssueCommands(program);
|
||||
registerAgentCommands(program);
|
||||
|
|
|
|||
|
|
@ -380,13 +380,17 @@ function shellQuote(value: string): string {
|
|||
|
||||
function isManagedShimContents(contents: string): boolean {
|
||||
const lines = contents.split("\n");
|
||||
// Accept the original pinned-runtime shim so upgrades can replace it.
|
||||
const withRuntimePath = lines.length === 6;
|
||||
const execIndex = withRuntimePath ? 4 : 3;
|
||||
return (
|
||||
lines.length === 5 &&
|
||||
(lines.length === 5 || withRuntimePath) &&
|
||||
lines[0] === "#!/bin/sh" &&
|
||||
lines[1] === `# ${MANAGED_SHIM_MARKER}` &&
|
||||
lines[2] === "set -eu" &&
|
||||
/^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[3]) &&
|
||||
lines[4] === ""
|
||||
(!withRuntimePath || /^export PATH='(?:[^']|'"'"')+':"\$\{PATH:-\/usr\/local\/bin:\/usr\/bin:\/bin\}"$/.test(lines[3])) &&
|
||||
/^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[execIndex]) &&
|
||||
lines[execIndex + 1] === ""
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -399,7 +403,9 @@ export function writeManagedShim(paths = resolveInstallStorePaths()): void {
|
|||
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true, mode: 0o755 });
|
||||
assertManagedShimWritable(paths);
|
||||
const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js");
|
||||
const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`;
|
||||
// ACP servers and package-manager shims use /usr/bin/env node. Pin their
|
||||
// runtime too, even when systemd/launchd supplies a different PATH.
|
||||
const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexport PATH=${shellQuote(path.dirname(process.execPath))}:"\${PATH:-/usr/local/bin:/usr/bin:/bin}"\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`;
|
||||
writeFileAtomic(paths.shimPath, contents, 0o755);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ describe("isSupportedNodeVersion", () => {
|
|||
expect(warning).toContain(NODE_VERSION_INSTALL_GUIDE_URL);
|
||||
expect(warning).toContain("piped install.sh form cannot upgrade");
|
||||
expect(warning).toContain("Restart Paperclip after upgrading");
|
||||
expect(warning).toContain(process.execPath);
|
||||
expect(warning).toContain("startup executable and PATH");
|
||||
});
|
||||
|
||||
it("emits at most one warning when CLI and server boot in the same process", () => {
|
||||
|
|
|
|||
|
|
@ -4,5 +4,5 @@
|
|||
"outDir": "dist",
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src", "../packages/shared/src", "../packages/plugins/create-paperclip-plugin/src"]
|
||||
"include": ["src", "../packages/shared/src", "../server/src/types/express.d.ts", "../packages/plugins/create-paperclip-plugin/src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ When a task produces a user-inspectable deliverable file:
|
|||
4. Link the printed attachment URL in the final issue comment.
|
||||
5. Then set the final issue status.
|
||||
|
||||
For a response that is explicitly intended for an external chat conversation,
|
||||
also pass each intended file with `paperclipai issue comment --attachment-id
|
||||
<id>`. Paperclip binds only those exact uploaded files to that comment; other
|
||||
task attachments remain internal.
|
||||
|
||||
Final comments should name and link the uploaded artifact or work product, not
|
||||
just the local filesystem path. For workspace-only files, include the work
|
||||
product title and recorded relative path. Local paths can be included as
|
||||
|
|
|
|||
92
doc/CLI.md
92
doc/CLI.md
|
|
@ -154,6 +154,90 @@ Choose local instance:
|
|||
npx paperclipai run --instance dev
|
||||
```
|
||||
|
||||
## Isolated Manual Test Drives
|
||||
|
||||
`paperclipai test-drive` creates or reuses an isolated local data directory,
|
||||
ensures one usable CEO agent exists in a fresh database, starts Paperclip in the
|
||||
foreground, and opens the browser after initialization succeeds. It never
|
||||
installs a background service and never creates a goal, project, issue, task,
|
||||
or heartbeat.
|
||||
|
||||
```sh
|
||||
npx paperclipai test-drive \
|
||||
[-d, --data-dir <path>] \
|
||||
[--company-name <name>] \
|
||||
[--agent-name <name>] \
|
||||
[--harness <claude|codex|opencode>] \
|
||||
[--model <model-id>] \
|
||||
[--api-key-env <variable> | --api-key <value>] \
|
||||
[--no-browser]
|
||||
```
|
||||
|
||||
Defaults are `Test Company`, a `CEO` agent with the `ceo` role, and the Claude
|
||||
harness. Without `--data-dir`, every invocation creates a unique OS temporary
|
||||
directory and prints its absolute path. The directory is retained after exit
|
||||
for inspection. An explicit data directory is reused and is never reset. The
|
||||
reused directory must use Paperclip's embedded database; `DATABASE_URL`,
|
||||
`DATABASE_MIGRATION_URL`, and configs with `database.mode: postgres` are
|
||||
rejected so test-drive cannot mutate an external database. The server also
|
||||
ignores the invocation directory's `.env` for test-drive launches, while still
|
||||
loading the selected instance's own environment file. Reused directories also
|
||||
retain the normal guard against colliding with a managed Paperclip service. The
|
||||
server uses the first available loopback port at or above `3100`, so an
|
||||
unrelated local Paperclip process can remain running.
|
||||
|
||||
Harness configuration:
|
||||
|
||||
| Harness | Agent adapter | Agent credential variable | Model |
|
||||
| --- | --- | --- | --- |
|
||||
| `claude` | `claude_local` | `ANTHROPIC_API_KEY` | Optional; omitted uses the adapter default |
|
||||
| `codex` | `codex_local` | `OPENAI_API_KEY` | Optional; omitted uses the adapter default |
|
||||
| `opencode` | `opencode_local` | `OPENROUTER_API_KEY` | Required and must begin with `openrouter/` |
|
||||
|
||||
OpenCode model references retain their complete path, including additional
|
||||
slashes:
|
||||
|
||||
```sh
|
||||
OPENROUTER_API_KEY=... npx paperclipai test-drive \
|
||||
--harness opencode \
|
||||
--model openrouter/anthropic/claude-sonnet-4.5
|
||||
```
|
||||
|
||||
Credentials come from `--api-key`, the variable named by `--api-key-env`, or
|
||||
the harness's canonical environment variable shown in the table. `--api-key`
|
||||
and `--api-key-env` are mutually exclusive. A custom source variable is still
|
||||
stored and projected under the canonical target variable:
|
||||
|
||||
```sh
|
||||
MY_ROUTER_KEY=... npx paperclipai test-drive \
|
||||
--harness opencode \
|
||||
--model openrouter/openai/gpt-5.4 \
|
||||
--api-key-env MY_ROUTER_KEY
|
||||
```
|
||||
|
||||
Credentials are stored through Paperclip's user-secret reference path and are
|
||||
redacted from Paperclip command output. Paperclip does not print `--api-key`,
|
||||
and it removes the value from its JavaScript argument view immediately after
|
||||
Commander parses it. Paperclip does not put the raw argument list in telemetry,
|
||||
API metadata, or diagnostics. Command wrappers, operating-system process
|
||||
listings, and shell history can still expose values passed in arguments. This
|
||||
is an explicit tradeoff for the local test-drive workflow. Prefer an exported
|
||||
canonical variable or `--api-key-env` when that matters. Provider connectivity,
|
||||
local harness installation, credential validity, and model availability are
|
||||
intentionally checked only when the agent first runs.
|
||||
|
||||
When invoked inside a linked Git worktree, the command ignores inherited
|
||||
`PAPERCLIP_IN_WORKTREE` state, launches in worktree mode, and verifies **Run
|
||||
tasks in this worktree** is armed for the current instance before opening the
|
||||
browser. In a primary checkout or non-Git directory it launches without
|
||||
worktree mode and does not alter the setting. On reuse, if any company already
|
||||
exists, all bootstrap flags are ignored and companies, agents, and secrets are
|
||||
left untouched; worktree-setting reconciliation is the only permitted
|
||||
mutation.
|
||||
|
||||
Use `--no-browser` for a foreground instance that prints its ready URL without
|
||||
opening it.
|
||||
|
||||
## Install, Update, And Uninstall
|
||||
|
||||
Managed installs keep CLI payloads under `~/.paperclip/cli`, expose a stable
|
||||
|
|
@ -343,7 +427,7 @@ npx paperclipai issue get <issue-id-or-identifier>
|
|||
npx paperclipai issue create --company-id <company-id> --title "..." [--description "..."] [--status todo] [--priority high]
|
||||
npx paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
|
||||
npx paperclipai issue delete <issue-id> --yes
|
||||
npx paperclipai issue comment <issue-id> --body "..." [--reopen]
|
||||
npx paperclipai issue comment <issue-id> --body "..." [--attachment-id <id...>] [--reopen]
|
||||
npx paperclipai issue comments <issue-id> [--limit 50]
|
||||
npx paperclipai issue comment:get <issue-id> <comment-id>
|
||||
npx paperclipai issue comment:delete <issue-id> <comment-id>
|
||||
|
|
@ -716,8 +800,10 @@ Preview/install options:
|
|||
`paperclipai company current --json`, or `PAPERCLIP_COMPANY_ID` to select the
|
||||
target company. `company list` falls back to the scoped current company when
|
||||
board-wide listing is forbidden. `teams install` creates agents and therefore
|
||||
requires board authentication, an `agents:create` grant, or an agent with
|
||||
explicit `canCreateAgents` permission.
|
||||
requires board authentication, an `agents:create` grant, or an agent with the
|
||||
`canCreateAgents` permission (enabled by default for newly created
|
||||
standard-trust agents; low-trust agents and pre-existing agents without an
|
||||
explicit value stay disabled).
|
||||
- `--request-approval-on-forbidden` turns a 403 install denial into a linked
|
||||
board approval request instead of a raw failed command; use
|
||||
`--approval-issue-id <id>` to attach it to a specific issue. During Paperclip
|
||||
|
|
|
|||
|
|
@ -122,8 +122,10 @@ All of these are optional; when unset, the driver defaults apply and behavior is
|
|||
```sh
|
||||
DATABASE_PREPARED_STATEMENTS=false # required for transaction-mode poolers; default: enabled
|
||||
DATABASE_POOL_MAX=25 # connection pool size; default: 10
|
||||
DATABASE_IDLE_TIMEOUT_SECONDS=60 # close idle pooled connections; default: keep open
|
||||
DATABASE_IDLE_TIMEOUT_SECONDS=60 # close idle pooled connections; default: 60 (0 = keep open)
|
||||
DATABASE_CONNECT_TIMEOUT_SECONDS=10 # default: 30
|
||||
DATABASE_MAX_LIFETIME_SECONDS=1800 # recycle a pooled connection after this long; default: 30-60 min (random)
|
||||
DATABASE_APPLICATION_NAME=paperclip # application_name in pg_stat_activity; default: paperclip
|
||||
```
|
||||
|
||||
### Push the schema
|
||||
|
|
@ -245,12 +247,59 @@ finalization ledger, whose retry time and owner lease are checked under a row
|
|||
lock. None of these writes selects a runtime or changes a legacy run's execution
|
||||
path.
|
||||
|
||||
Durable agent session goals are an additive projection on
|
||||
`agent_task_sessions`, distinct from the business-goal hierarchy. The row stores
|
||||
the negotiated goal capability, normalized snapshot and status, desired state,
|
||||
provider source cursor, monotonic projection revision, and observation time.
|
||||
`agent_session_goal_actions` is the control outbox: `(session_id, request_id)`
|
||||
is unique, so retries return the original accepted action. Provider source
|
||||
ordering fences duplicate and stale updates, and a cleared projection retains
|
||||
its revision/cursor tombstone so an older provider event cannot resurrect it.
|
||||
|
||||
Issue `status_version` advances only when `status` changes. The JavaScript backup
|
||||
path includes user-defined functions and triggers so a restored database keeps
|
||||
that invariant. Removing or disabling a future native rollout flag must not
|
||||
delete these records; persisted experimental runs remain available for recovery
|
||||
and inspection.
|
||||
|
||||
`native_run_finalizations` also stores restart ownership and recovery state.
|
||||
The controller owner is a server boot id, PID, operating-system process-start
|
||||
timestamp, and monotonically increasing controller generation. Recovery writes
|
||||
its correlated request id, current state, and a bounded JSON history. A
|
||||
successor can take the lease immediately only when coordinated handoff or PID
|
||||
and process-start evidence proves the prior controller is gone, or when the
|
||||
lease expires. Recovery generation changes do not increment the independent
|
||||
provider-attempt counter.
|
||||
|
||||
## Telegram private draft identities
|
||||
|
||||
`chat_telegram_draft_ids` is a content-free, instance-wide PostgreSQL sequence,
|
||||
not a company-owned record. Telegram's native Stop callback carries a draft ID
|
||||
but no actor or Paperclip generation. IDs therefore must not be recycled when
|
||||
a transaction rolls back or an endpoint/company is deleted and its bot is
|
||||
connected again. The sequence allocates positive 31-bit IDs without cycling;
|
||||
exhaustion refuses new draft allocation rather than wrapping or falling back to
|
||||
random IDs. Never reset it as part of chat cleanup.
|
||||
|
||||
The matching `chat_actions` entry remains company/endpoint-scoped and binds the
|
||||
draft to its exact conversation, publication attempt, runtime, credential and
|
||||
approved text. Stop can suppress that private draft's final publication; it
|
||||
cannot cancel a task or model run. Logical backups preserve the sequence, but
|
||||
restoring an older database may roll back its high-water mark: disaster recovery
|
||||
must not assume stale provider Stop events are safe to reuse. That restore
|
||||
boundary is not qualified by the rollback/concurrency regression.
|
||||
|
||||
## Attachment upload provenance
|
||||
|
||||
`issue_attachments.originating_run_id` records server-derived run attribution at
|
||||
upload time. It is not writable through attachment or work-product update APIs.
|
||||
Legacy attachments and uploads without a registered run keep a null value; the
|
||||
migration deliberately does not infer attribution from mutable work products.
|
||||
Deleting the originating run clears the reference and fails closed for automatic
|
||||
chat handoff. An agent's external file selection must match the attachment's
|
||||
company, task, agent, and originating run. Editing or recreating a work-product
|
||||
record cannot reassign that authority to a later run.
|
||||
|
||||
## Question-response delivery receipts
|
||||
|
||||
`issue_question_response_deliveries` is the retry-safe, content-free outbox for
|
||||
|
|
@ -337,3 +386,18 @@ pnpm secrets:migrate-inline-env --apply
|
|||
```
|
||||
|
||||
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
|
||||
|
||||
### Persistent agent conversations
|
||||
|
||||
Migration `0274_agent_chat.sql` adds conversation identity/state and session generation/boundary columns to `issues`, plus idempotent client request IDs and processed session-boundary generations to `issue_comments`. The company/agent/user unique index resolves concurrent first writes to one issue. A check constraint preserves the assigned-agent identity and prevents terminal conversation status. Comment request IDs are unique per issue and user. There is no separate chat/message store. Provider sessions continue to use `agent_task_sessions`; `/new` removes only the matching conversation session, and session writers fence stale generations against the issue row.
|
||||
|
||||
## Legacy controller ownership
|
||||
|
||||
Legacy run claims atomically record `controller_boot_id`, a database-clock
|
||||
`controller_lease_expires_at`, and `execution_stage` before workspace provisioning.
|
||||
The lease renews independently of output. A different container must not infer
|
||||
controller death from its own process map or numeric PIDs. Expiration grants
|
||||
cleanup authority; it does not prove that remote inference has stopped. Recovery
|
||||
revokes the previous boot identity with a conditional update. Its own claim also
|
||||
expires so another sweep can finish cleanup after a restart. Historical rows keep
|
||||
null ownership fields and follow the previous recovery path.
|
||||
|
|
|
|||
|
|
@ -163,6 +163,12 @@ only to real browser session actors in `authenticated/private`; unauthenticated
|
|||
requests, agent keys, board API keys, and local implicit board actors are
|
||||
rejected.
|
||||
|
||||
This is intentionally a first-claim bootstrap contract: before an instance
|
||||
admin exists, the first authenticated browser session that completes the claim
|
||||
wins. Operators must keep a `bootstrap_pending` private deployment on a trusted
|
||||
network and complete setup before admitting untrusted users. This behavior is
|
||||
not an account-recovery or public-deployment mechanism.
|
||||
|
||||
The CLI fallback remains supported in all authenticated setup states:
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -82,6 +82,12 @@ pnpm build-storybook
|
|||
|
||||
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
|
||||
|
||||
Use **Chat & Comments → Issue Thread Interactions → Composer Questions Auto Advance**
|
||||
to try the paged composer form. A single selection shows a brief checked-state animation before advancing to the
|
||||
next question. Reduced-motion mode advances without animation.
|
||||
Multi-select and custom answers wait for Next, and the final page waits for
|
||||
Submit answers. The adjacent **Verified** story exercises the full flow.
|
||||
|
||||
The Storybook visual regression suite uses external PNG baselines instead of
|
||||
committed screenshots:
|
||||
|
||||
|
|
@ -110,6 +116,70 @@ workflow manually, to produce downloadable Playwright report/test-result
|
|||
artifacts. Normal PR visual runs use read-only repository permissions and do not
|
||||
upload or mutate baseline objects.
|
||||
|
||||
### Publish a branch Storybook
|
||||
|
||||
CODEOWNERS can publish a repository branch through **Actions → Storybook Deploy →
|
||||
Run workflow**. Keep the workflow branch on `master` and enter the source branch
|
||||
in `branch`. The source branch does not need to contain the workflow. Leaving
|
||||
`branch` empty publishes the selected workflow branch's dispatched commit.
|
||||
|
||||
```sh
|
||||
gh workflow run storybook-deploy.yml --ref master -f branch=your-branch
|
||||
```
|
||||
|
||||
The existing **Storybook Visual** workflow also offers a `deploy_preview` checkbox,
|
||||
which publishes through the same workflow instead of running visual tests:
|
||||
|
||||
```sh
|
||||
gh workflow run storybook-visual.yml --ref master -f deploy_preview=true -f branch=your-branch
|
||||
```
|
||||
|
||||
Approve the `storybook-deploy` environment as a CODEOWNER. The workflow summary
|
||||
links the **stable branch URL** and **this build**. The run also uploads a
|
||||
`storybook-deployment-<run-id>-<attempt>` artifact containing
|
||||
`storybook-deployment.md` with both links and the source commit. Different branches have
|
||||
different URLs; publishing one never replaces another. Redeploying the same
|
||||
branch updates its stable URL only after all files for the new build are uploaded.
|
||||
Previous build links keep working. The branch entry preserves Storybook query
|
||||
parameters and fragments when redirecting to the completed build.
|
||||
|
||||
Bookmark URLs use `storybook/branches/<branch>/`, for example
|
||||
`https://d1p6rlowie26tp.cloudfront.net/storybook/branches/master/`.
|
||||
Copy the **stable branch URL** from the run summary when saving a bookmark;
|
||||
opening it redirects to the latest published build. Branch names preserve case.
|
||||
Characters other than letters, digits, `_`, and `-` use `~HH` UTF-8 escapes, so
|
||||
`feature/foo` becomes `feature~2Ffoo` and stays distinct from `feature-foo`.
|
||||
Names ending in a hyphen and 16 lowercase hex digits escape that hyphen to
|
||||
reserve the existing build directories. Very long names use a hash suffix.
|
||||
Existing hashed branch URLs keep updating and remain valid. Build files remain
|
||||
under `storybook/branches/<readable-branch>-<hash>/builds/<run-id>-<attempt>/`.
|
||||
`deployment.json` in each build records its branch, source commit and URLs.
|
||||
Builds run independently; publication is serialized per branch. Retained builds
|
||||
are not automatically deleted and will accumulate until an operator prunes them.
|
||||
|
||||
Publishing requires both the original actor and the current rerunner to be
|
||||
individual GitHub accounts named in `.github/CODEOWNERS` on the current default
|
||||
branch. Comments, teams and email entries do not grant access. Authorization runs
|
||||
before the build and again before deployment, including deployment-only reruns.
|
||||
GitHub also requires a CODEOWNER environment approval, so editing authorization
|
||||
code on a branch cannot grant AWS access without an authorized reviewer.
|
||||
|
||||
The build downloads the public source archive with no GitHub token permissions,
|
||||
AWS credentials or repository secrets. Dependency caching and install lifecycle
|
||||
scripts are disabled. The separate publisher uses GitHub OIDC to assume a role limited to
|
||||
`storybook/branches/*`. It treats the build artifact as static files and runs only
|
||||
the publisher from the workflow checkout. It cannot delete objects, change AWS
|
||||
settings, or overwrite the runner dashboard. The Storybook site itself is public.
|
||||
Pushes and PR events never publish it.
|
||||
|
||||
The existing S3 bucket and CloudFront distribution also serve runner reports in
|
||||
separate prefixes. GitHub Pages and its dashboard workflow are independent.
|
||||
See [Storybook deployment setup](STORYBOOK-DEPLOYMENT.md) for the environment,
|
||||
repository variables, AWS policies and one-time operator setup.
|
||||
|
||||
GitHub requires a new dispatch workflow to exist on the default branch before
|
||||
it becomes a manual entry point.
|
||||
|
||||
## UI Fonts And Screenshots
|
||||
|
||||
The board UI ships its own sans-serif webfont assets in `ui/public/fonts/`.
|
||||
|
|
@ -131,6 +201,13 @@ pnpm dev:stop
|
|||
|
||||
`pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server.
|
||||
|
||||
Worktree dependency provisioning records its fingerprint only after a successful
|
||||
install. Frozen installs with outdated lockfiles or patched-dependency hash
|
||||
mismatches retry once without `--frozen-lockfile`; other failures retain their
|
||||
exit status. Patch contents are part of the install fingerprint. Generated
|
||||
lockfile changes remain local to the worktree; the repository's lockfile bot
|
||||
owns committed updates.
|
||||
|
||||
## Hot-Restart Deploys
|
||||
|
||||
Primary-instance rebuilds that restart `paperclip.service` can request one-shot live-run adoption instead of using the normal graceful shutdown drain. Before restarting the service, write the marker from the newly staged app with the current service PID:
|
||||
|
|
@ -153,7 +230,7 @@ at least one identity source. Supported-platform process probes fail explicitly
|
|||
instead of silently treating a live PID as either the original owner or a
|
||||
recycled process when identity cannot be established.
|
||||
|
||||
Use `--drain-required` only when the deploy intentionally requires the old terminate-and-retry behavior. Without that flag, the old server verifies that the marker targets its own PID, stops new scheduler work, waits for any queue-claim callback already in flight, snapshots currently running heartbeat run IDs and child PIDs, and skips the shutdown drain so eligible detached local-agent processes can keep running. ACP-backed local runs use server-owned stdio and cannot survive their parent server, so the old server instead persists their complete snapshot, changes the marker to `drainRequired` with `drainReason: "active_acp_run"`, and drains only those runs to queued retries. Detached CLI runs remain eligible for adoption during the same mixed restart. If an ACP process terminates but its terminal run update does not persist, startup classifies it as lost with reason `selective_drain_not_finalized` rather than treating the drain as successful. On startup the new server writes `$PAPERCLIP_HOME/instances/${PAPERCLIP_INSTANCE_ID:-default}/hot-restart-report.json` with `previousServerPid`, `newServerPid`, `previousServerVersion`, `newServerVersion`, `drainReason`, `adoptedRunIds`, `finalizedWhileDownRunIds`, `lostRunIds`, and per-run classifications before the normal orphan reaper runs.
|
||||
Use `--drain-required` only when the deploy intentionally requires the old terminate-and-retry behavior. Without that flag, the old server verifies that the marker targets its own PID, stops new scheduler work, waits for any queue-claim callback already in flight, snapshots currently running heartbeat run IDs and child PIDs, and skips the shutdown drain so eligible detached local-agent processes can keep running. ACP-backed local runs use server-owned stdio and cannot survive their parent server, so the old server instead persists their complete snapshot, changes the marker to `drainRequired` with `drainReason: "active_acp_run"`, and drains only those runs to bounded conversation retries. These retries resume the prior session when compatible, otherwise carry the full task conversation into a fresh session. They do not automatically replay tool calls or require receipts for every prior action. Detached CLI runs remain eligible for adoption during the same mixed restart. If an ACP process terminates but its terminal run update does not persist, startup classifies it as lost with reason `selective_drain_not_finalized` rather than treating the drain as successful. On startup the new server writes `$PAPERCLIP_HOME/instances/${PAPERCLIP_INSTANCE_ID:-default}/hot-restart-report.json` with `previousServerPid`, `newServerPid`, `previousServerVersion`, `newServerVersion`, `drainReason`, `adoptedRunIds`, `finalizedWhileDownRunIds`, `lostRunIds`, and per-run classifications before the normal orphan reaper runs.
|
||||
|
||||
When Paperclip manages embedded PostgreSQL, it suppresses that dependency's eager
|
||||
`SIGINT`/`SIGTERM` cleanup hooks. Paperclip owns signal ordering so the heartbeat
|
||||
|
|
@ -275,6 +352,28 @@ These browser suites are intended for targeted local verification and CI, not th
|
|||
|
||||
For normal issue work, start with the smallest targeted check that proves the change. Reserve repo-wide typecheck/build/test runs for PR-ready handoff or changes broad enough that narrow checks do not cover the risk.
|
||||
|
||||
### Task search evaluation
|
||||
|
||||
The task search relevance rubric and regression corpus are documented in
|
||||
[SEARCH.md](SEARCH.md). Run the real PostgreSQL relevance suite with:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
|
||||
```
|
||||
|
||||
Set `SEARCH_EVAL_SCALE=1` to additionally measure a disposable 10,000-task,
|
||||
30,000-comment dataset. `SEARCH_EVAL_REPORT=/tmp/search-quality.json` saves
|
||||
per-query results and latency measurements; scale measurements are opt-in.
|
||||
|
||||
### Recent task ordering
|
||||
|
||||
The streamlined sidebar keeps five recent tasks per company and account in browser
|
||||
storage. It sorts by the newest observed task or comment activity, not by live-run
|
||||
state. Older detail responses cannot move the stored activity time backward.
|
||||
Activity-only reorderings wait for one second without further activity changes;
|
||||
new and removed tasks appear immediately. Titles, status, and live indicators stay
|
||||
current during that delay.
|
||||
|
||||
## One-Command Local Run
|
||||
|
||||
For a first-time local install, you can bootstrap and run in one command:
|
||||
|
|
@ -305,6 +404,63 @@ pnpm paperclipai run
|
|||
2. `paperclipai doctor` with repair enabled
|
||||
3. starts the server when checks pass
|
||||
|
||||
### One-command isolated manual test drive
|
||||
|
||||
Use `test-drive` when you want to exercise the UI from a fresh checkout or SHA
|
||||
without completing onboarding by hand:
|
||||
|
||||
```sh
|
||||
ANTHROPIC_API_KEY=... node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive
|
||||
OPENAI_API_KEY=... node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive --harness codex --model gpt-5.4
|
||||
OPENROUTER_API_KEY=... node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive \
|
||||
--harness opencode \
|
||||
--model openrouter/anthropic/claude-sonnet-4.5
|
||||
```
|
||||
|
||||
The command creates a trusted-loopback instance in a unique OS temporary data
|
||||
directory, prints the absolute directory, runs onboarding and doctor
|
||||
non-interactively, creates `Test Company` with a `CEO` agent, then opens the
|
||||
browser. It runs in the foreground, does not install a background service, and
|
||||
does not create a goal, project, issue, task, or first heartbeat. Temporary
|
||||
directories are retained for inspection. Use `--data-dir <path>` to reuse one
|
||||
or `--no-browser` to suppress browser opening. Reused directories must use the
|
||||
embedded database: the command rejects database URL environment overrides and
|
||||
configs with `database.mode: postgres`, suppresses the invocation directory's
|
||||
`.env` when the server starts, and keeps the normal managed-service collision
|
||||
guard. The selected instance's own environment file still loads. The command
|
||||
selects the first available loopback port at or above `3100`.
|
||||
|
||||
Source-checkout startup builds the shared and plugin SDK packages when needed.
|
||||
It prints build progress and any wait for another build. Interrupted builds
|
||||
release their lock after the compiler stops; later startups recover locks whose
|
||||
owner and compiler have exited. Empty locks from older versions are recovered
|
||||
once they are at least two minutes old. The command remains in the foreground
|
||||
after printing its ready URL to serve the instance; use Ctrl-C to stop it.
|
||||
Each package gets a completion marker only after a successful build. A hard
|
||||
kill leaves that marker absent, so the next startup rebuilds partial output.
|
||||
The marker records source and output content fingerprints, so recovery does
|
||||
not depend on filesystem timestamp precision. Direct `tsc` builds that produce
|
||||
identical output reuse the marker. Changed or partial output is rebuilt once
|
||||
before later startups reuse the completed build.
|
||||
|
||||
Claude uses `ANTHROPIC_API_KEY`; Codex uses `OPENAI_API_KEY`; OpenCode uses
|
||||
`OPENROUTER_API_KEY` and requires an `openrouter/...` model. `--api-key-env`
|
||||
can name a different source variable while the agent still receives the
|
||||
canonical variable. `--api-key <value>` is also supported and is mutually
|
||||
exclusive with `--api-key-env`; Paperclip redacts it from its own output, but
|
||||
also removes it from the JavaScript argument view before telemetry, diagnostics,
|
||||
or server startup. Wrappers, operating-system process listings, and shell
|
||||
history may still expose argument values. This is an explicit local test-drive
|
||||
tradeoff; use an environment-backed input when that exposure is not acceptable. In a
|
||||
linked Git worktree the command sets worktree runtime mode and safely arms
|
||||
**Run tasks in this worktree** for the current isolated instance. Primary
|
||||
checkouts and non-Git directories leave that experimental setting unchanged.
|
||||
|
||||
If the selected data directory already contains any company, `test-drive`
|
||||
preserves all companies, agents, and secrets and ignores the bootstrap flags.
|
||||
The worktree execution setting is the only value it may reconcile in that
|
||||
case.
|
||||
|
||||
## Docker Quickstart (No local Node install)
|
||||
|
||||
Build and run Paperclip in Docker:
|
||||
|
|
@ -450,6 +606,8 @@ Paperclip applies one process-wide scheduler to expensive host-side workspace Gi
|
|||
|
||||
The cache intentionally trades up to a few seconds of changed-file freshness for stable server latency. The file browser retains an explicit refresh action, does not start its query while the panel or browser tab is hidden, and presents overloads as retryable failures rather than an empty workspace. A full queue returns `503` with code `workspace_git_scan_saturated`; a scan exceeding its wall-clock limit returns `504` with code `workspace_git_scan_timeout`. Both responses include `Retry-After: 1`.
|
||||
|
||||
Sandbox Git sync treats only the selected repository root as a clone source. A selected subfolder uses directory sync within that folder, applies the enclosing repository's ignore rules, and does not transfer parent files or Git history.
|
||||
|
||||
Environment overrides:
|
||||
|
||||
- `PAPERCLIP_WORKSPACE_GIT_SCAN_CONCURRENCY` (default `2`, range `1`–`16`)
|
||||
|
|
@ -741,12 +899,32 @@ When a workspace service runs Paperclip for browser OAuth QA, configure its `exp
|
|||
|
||||
## Paperclip Runner Adapter Conversion
|
||||
|
||||
The experimental Paperclip Runner currently qualifies four local profiles:
|
||||
Codex, OpenCode, ACPX Claude, and ACPX Codex. Changing an existing agent to
|
||||
`paperclip_runner` remains supported only from `codex_local`; create the other
|
||||
profiles explicitly after enabling the single **Paperclip Runner** experimental
|
||||
setting. Onboarding continues to create legacy adapters. Disabling the setting
|
||||
blocks fresh native starts without hiding or corrupting persisted native runs.
|
||||
The experimental Paperclip Runner offers native Codex, OpenCode, and **ACPX
|
||||
Claude**. Converting an existing Claude, Codex, or OpenCode agent selects its
|
||||
corresponding provider, preserves compatible models, credentials, workspace,
|
||||
and instructions, and resets execution sessions while retaining run history.
|
||||
Other adapters require an explicit provider choice. Legacy ACPX Codex agent
|
||||
settings normalize to native Codex on configuration updates and before fresh
|
||||
runs; immutable run descriptors remain readable. The **Paperclip Runner**
|
||||
experimental setting and company access checks still apply.
|
||||
|
||||
Agent configuration uses the same section layout across adapters: model and
|
||||
provider belong to **Adapter**, environment variables have their own section,
|
||||
and command/extra arguments are folded under **Configuration → Advanced**.
|
||||
Lifecycle, timeout, and interrupt grace settings live under **Advanced Run
|
||||
Policy**. Permission selectors with a single valid mode are hidden; a saved
|
||||
unsupported mode still exposes remediation.
|
||||
|
||||
Model catalogs and refresh follow the selected provider. ACPX Claude uses the
|
||||
normal Claude catalog and accepts custom model IDs; the exact ID is sent to
|
||||
Claude, which can reject unavailable models. Package/version verification is
|
||||
independent of model selection. Environment tests verify runtime installation;
|
||||
a successful provider run additionally verifies credentials and model access.
|
||||
|
||||
ACPX Claude supports Linux x64 and macOS ARM64/x64 with pinned SDK executables.
|
||||
On macOS the launcher uses private verified module/executable snapshots instead
|
||||
of Linux `/proc` descriptors. Dependency isolation, process ownership, and
|
||||
cancellation remain enforced; the snapshot is removed when the provider exits.
|
||||
|
||||
Native Codex is qualified only with `codexPermissionMode: "never"`. The create
|
||||
and edit surfaces do not offer `on-request` or `untrusted`, and a persisted
|
||||
|
|
@ -763,6 +941,73 @@ agent workspace. The host `HOME` itself, a directory that contains it, a
|
|||
filesystem root, a `CODEX_HOME` overlap, or a canonical path outside the
|
||||
assigned workspace is rejected before provider startup.
|
||||
|
||||
### Preinstalled remote runner runtime
|
||||
|
||||
For fast sandbox startup, bake `paperclip-runnerd` and the latest stable agent
|
||||
CLIs into the sandbox image. Keep one version of each CLI shared by native and
|
||||
local adapters; never retain an older global CLI beside a newer private copy.
|
||||
Pin the resolved releases at image build time for reproducibility and refresh
|
||||
the runner's qualification versions and binary digests together with those pins.
|
||||
The ACP bridges remain separately qualified protocol dependencies.
|
||||
|
||||
Native discovery checks `/opt/paperclip-runner/bin`, then `$HOME/.local/bin`,
|
||||
then PATH. Any preferred-directory entry must launch the same shared CLI that
|
||||
normal adapters use. Discovery picks the first executable; it does not compare
|
||||
versions across directories. With these artifacts preinstalled, startup links
|
||||
and verifies them without uploading a binary or installing packages. Deploy
|
||||
the updated sandbox image with the matching runner qualification changes.
|
||||
|
||||
### Native runner restart recovery
|
||||
|
||||
Paperclip Runner keeps its heartbeat run, native session, logical runner, and
|
||||
provider session identities across server restarts. A coordinated hot restart
|
||||
registers a correlated recovery request before it signals the dev supervisor.
|
||||
An uncoordinated server restart uses the same durable recovery classifier
|
||||
without trusting a handoff marker.
|
||||
|
||||
Startup binds the HTTP and PRP listener before it classifies native runs. Public
|
||||
health reports a startup state until every candidate is reattached, dispatched
|
||||
for same-run resume, finalized from durable evidence, or held for explicit
|
||||
ownership evidence. Scheduling and generic orphan recovery start only after
|
||||
that classification finishes.
|
||||
|
||||
- A verified live runner re-registers its existing PRP authority and reconnects
|
||||
with the same operating-system PID. Paperclip does not spawn a competing
|
||||
runner.
|
||||
- A verified dead runner starts a replacement from the same durable root and
|
||||
resumes the same provider checkpoint. Only the operating-system PID changes.
|
||||
- A runner that died before its first authenticated connection can restart on
|
||||
the same run only when its durable root proves that no provider authority or
|
||||
checkpoint exists. Paperclip quarantines the incomplete root first.
|
||||
- A live but mismatched or unverifiable process fails closed. Paperclip does not
|
||||
signal it or spawn a replacement.
|
||||
- A persisted proposed or terminal result is reconciled before any runner or
|
||||
provider work starts, so restart recovery cannot submit a duplicate turn.
|
||||
- On the next run, a completed local Codex session whose warm controller died
|
||||
before suspension is recovered automatically, including a uniquely verified
|
||||
checkpoint quarantined by older controllers. Paperclip requires matching
|
||||
database/session/provider identities, a settled terminal journal, no pending
|
||||
commands or active provider turn, and a confirmed-dead process and process
|
||||
group. It seals the old authority for normal epoch rotation and preserves the
|
||||
Codex thread and goal state. Empty retry directories do not prevent recovery;
|
||||
conflicting histories, changed profiles, and live or unverifiable owners do.
|
||||
|
||||
A resumed sandbox lease can contain a workspace whose provider never started. A new attempt may create its exact session directory only when durable control-plane evidence proves zero connections, zero events, and untouched bootstrap commands, and no backup or remote session directory exists. Directory creation is atomic; partial state or uncertain ownership remains blocked.
|
||||
|
||||
Run the credential-free real-process restart suite with:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner build:runner-binaries
|
||||
pnpm exec vitest run server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/live/runnerd-codex-transport.test.ts -t 'adopts a live runner'
|
||||
```
|
||||
|
||||
The suite uses isolated PostgreSQL state, isolated `PAPERCLIP_HOME` roots, real
|
||||
`runnerd` processes, and a deterministic fake Codex app server. It covers hot
|
||||
and hard restarts with live and dead runners, the result-finalization race,
|
||||
incomplete bootstrap, repeated crashes with steering, and fail-closed process
|
||||
identity mismatches.
|
||||
|
||||
## App-Shipped Skills Catalog
|
||||
|
||||
The Paperclip app ships a curated catalog of company skills out of the box. The
|
||||
|
|
@ -996,6 +1241,24 @@ broker hostname is resolved once and the request is pinned to the approved
|
|||
address; IPv4 and IPv6 link-local destinations remain denied even when their
|
||||
host is allowlisted.
|
||||
|
||||
## HTTP Adapter Private Endpoints
|
||||
|
||||
HTTP adapters can call public HTTP(S) endpoints by default. Requests use the
|
||||
same DNS-pinning guard as remote connections, do not follow redirects, and
|
||||
reject loopback, RFC1918/private, and link-local or cloud-metadata destinations.
|
||||
|
||||
Server owners can opt a trusted private service in with a comma-separated list
|
||||
of exact origins:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_HTTP_ADAPTER_PRIVATE_ENDPOINT_ALLOWLIST=http://hooks.internal.example:8080,https://10.0.0.42
|
||||
```
|
||||
|
||||
Each entry must contain only a scheme, hostname, and optional port. Paths,
|
||||
credentials, query strings, fragments, and wildcards are ignored. Matching is
|
||||
by exact normalized origin, so allowing one port does not allow another.
|
||||
Link-local destinations remain denied even when explicitly listed.
|
||||
|
||||
## Company Deletion Toggle
|
||||
|
||||
Company deletion is intended as a dev/debug capability and can be disabled at runtime:
|
||||
|
|
@ -1141,3 +1404,39 @@ Networking behavior for this smoke script:
|
|||
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
|
||||
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
|
||||
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `npx paperclipai allowed-hostname host.docker.internal` and restart Paperclip
|
||||
|
||||
### GitHub identity for shared agents
|
||||
|
||||
See [execution GitHub identity](execution-github-identity.md) for the operation-time credential contract, continuation rules, runtime rollout, and acceptance-test requirements.
|
||||
|
||||
|
||||
### Investigating polling load
|
||||
|
||||
The company heartbeat-run and live-run lists load secret registries in one
|
||||
company-scoped query per response. Registry reads project only
|
||||
`paperclipSecretRedactions` from the run context. They do not load the full
|
||||
prompt/context JSON. Decrypted values live only for that request and each run
|
||||
uses its own registry.
|
||||
|
||||
Hidden browser tabs suspend the company live-events connection and transcript
|
||||
log reads. Returning to a visible tab refreshes active queries once and resumes
|
||||
transcript reads from their retained offsets. A queued live-event invalidation
|
||||
that flushes after the tab hides marks data stale without starting a refetch.
|
||||
The developer-server health poll also stops in hidden tabs.
|
||||
|
||||
Workspace detail responses share concurrent Git inspections and reuse their
|
||||
results for up to five seconds after completion. The cache holds at most 256
|
||||
entries. Close-readiness checks, the terminal-workspace reaper, and the final
|
||||
cleanup validation still inspect Git afresh. A display result never authorizes
|
||||
worktree removal.
|
||||
|
||||
The connection-health sweep selects only due IDs in SQL before applying its
|
||||
limit. Legacy `paperclip_plugin` placeholder connections are excluded: their
|
||||
tools run in plugin workers and do not have remote MCP endpoints. These rows
|
||||
remain available; the sweep does not disable or delete plugin connections.
|
||||
|
||||
When investigating an overloaded instance, distinguish request amplification
|
||||
from stored configuration problems. Verify connection transport and endpoint
|
||||
fields before disabling a connection. Verify workspace ownership, active runs,
|
||||
Git state, and runtime-service readiness before closing a workspace. A missing
|
||||
URL or old workspace timestamp alone does not prove that a row is disposable.
|
||||
|
|
|
|||
|
|
@ -18,12 +18,55 @@ Build arguments:
|
|||
|-----|---------|---------|
|
||||
| `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) |
|
||||
| `USER_GID` | `1000` | GID for the container `node` group |
|
||||
| `CLI_TOOLS_CACHE_EPOCH` | empty | Refresh the CLI-install layer; CI supplies the current ISO week |
|
||||
| `PAPERCLIP_BUILD_VERSION` | empty | Runtime version when Git metadata is unavailable |
|
||||
| `PAPERCLIP_BUILD_COMMIT` | empty | Source commit written into the server build stamp and runtime environment |
|
||||
|
||||
Changing the build version or commit preserves the CLI-install cache. The
|
||||
tool layer refreshes when its weekly epoch, base image, installation command,
|
||||
or earlier build inputs change. Local builds can set a new epoch explicitly
|
||||
to refresh tools without clearing the entire build cache.
|
||||
|
||||
```sh
|
||||
docker build -t paperclip-local \
|
||||
--build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .
|
||||
```
|
||||
|
||||
## Cloud image addresses
|
||||
|
||||
The Docker workflow publishes the managed deployment image for Linux AMD64.
|
||||
`Cloud readiness` starts `Docker cloud` on each master push independently of the
|
||||
multi-platform self-hosted build. Different commits use separate concurrency groups and existing
|
||||
GitHub-hosted runners, so an older production or cloud build does not hold the
|
||||
new commit in a workflow queue. Available GitHub runner capacity still applies.
|
||||
Release tags and manual `Docker` dispatches call the same cloud build workflow.
|
||||
|
||||
Each commit exports to its own `buildcache-cloud-<FULL_SHA>` registry tag.
|
||||
Builds import the current commit and nine first-parent ancestors, plus the
|
||||
legacy `buildcache-cloud` fallback. This preserves reusable layers without
|
||||
letting concurrent builds overwrite one shared cache manifest. Retain recent
|
||||
cache tags if registry cleanup is configured; deleting them makes builds colder.
|
||||
|
||||
Cloud CI skips SDK and cache cleanup when both the Docker data filesystem and
|
||||
the checkout filesystem have at least 64 GiB available. Below that conservative
|
||||
headroom threshold, or when the measurement fails, it retains the existing
|
||||
cleanup. The threshold selects the fast path; it is not a new minimum disk
|
||||
requirement for local builds or smaller runners.
|
||||
|
||||
After the pushed image passes its Sentry and orphan-reaping checks, the workflow verifies its
|
||||
commit label and platform and adds `ghcr.io/paperclipai/paperclip:sha-<full-commit-sha>-cloud`.
|
||||
This address lets commit-based deployment tooling reuse the normal build.
|
||||
Existing short-SHA and release tags remain available.
|
||||
|
||||
The full-SHA tag identifies the source commit. It does not certify that source
|
||||
tests passed or that a compatible database migrator is available. Deployment
|
||||
tooling must still check those prerequisites and pin the resolved image digest;
|
||||
a rebuild of the same source can update the tag's digest.
|
||||
|
||||
The separate [cloud readiness check](cloud-build-readiness.md) combines source
|
||||
verification, successful cloud image checks, and exact-source migrator
|
||||
availability. It runs outside the full npm release's concurrency queue.
|
||||
|
||||
## One-liner (build + run)
|
||||
|
||||
```sh
|
||||
|
|
@ -295,3 +338,51 @@ 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. A pinned `cargo-chef` generates a dependency recipe in
|
||||
`runner-plan`. The separate `runner-deps` stage compiles that recipe with the
|
||||
package-owned Rust compiler. Both the dependency build and the real binary use
|
||||
the release profile and locked Cargo dependencies. The recipe stage never
|
||||
modifies source in the checkout.
|
||||
|
||||
Changes to Rust source or embedded protocol inputs rebuild the real binary but
|
||||
can reuse compiled dependencies when the recipe is unchanged. Dependency
|
||||
manifests, the Cargo lockfile, target metadata, or compiler changes invalidate
|
||||
the relevant cache. Ordinary server or UI changes can reuse the entire native
|
||||
build through the existing registry cache (`mode=max`). Each platform gets its
|
||||
own native build; no cross-architecture binary is reused. No additional GitHub
|
||||
Actions cache is created. A cold build also installs the recipe generator and
|
||||
compiles dependencies, so the savings apply after those layers are available.
|
||||
|
||||
Cloud builds import one registry cache: the first available full-SHA cache in
|
||||
the current commit's ten-entry first-parent ancestry, with the legacy cache
|
||||
as a final fallback. Each build still exports its own SHA cache with
|
||||
`mode=max`. In fresh-builder checks, importing several historical manifests
|
||||
missed native layers that a single matching manifest reused. The selector
|
||||
inspects metadata after Docker login, stops at the first available cache, and
|
||||
permits a cold build if no cache can be read.
|
||||
|
||||
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`.
|
||||
The check runs `bash scripts/check-docker-runner-cache.sh` against a disposable
|
||||
copy of tracked source and the actual Docker ignore rules. It compiles a baseline
|
||||
and exports a local cache, removes that builder, changes a Rust metadata constant,
|
||||
and rebuilds on a fresh builder using only the exported cache. It requires a
|
||||
cached dependency build, an unchanged dependency recipe, and changed metadata
|
||||
from the real binary. It also verifies that a dependency declaration change
|
||||
alters the recipe. The probe exports small metadata results instead of importing
|
||||
a large test image into the Docker daemon. Temporary builders and cache files
|
||||
are removed afterward. It catches missing embedded inputs before the post-merge
|
||||
build. It uses a GitHub-hosted runner with read-only repository access and never
|
||||
publishes images or registry caches. Allow up to 20 minutes for its cold build and
|
||||
source rebuild.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,45 @@ the same origin as the artifact as an independent trust anchor.
|
|||
Each installer flag also has a `PAPERCLIP_INSTALL_*` environment-variable
|
||||
equivalent. This helps where passing arguments through a pipe is awkward.
|
||||
|
||||
Codex ACP workspace sessions enable networking so agents can report task outcomes.
|
||||
To disable it explicitly, set `extraArgs` to
|
||||
`["-c", "sandbox_workspace_write.network_access=false"]`, or set
|
||||
`env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false"`. Execution-target network denial
|
||||
also remains enforced. Read-only ACP mode remains read-only.
|
||||
|
||||
## Node runtime used by background services
|
||||
|
||||
Check the Node executable used by the running service, not only `node --version`
|
||||
in an interactive shell. Systemd and launchd do not load shell version-manager
|
||||
configuration. A newer Node installed elsewhere does not upgrade a running
|
||||
service or change a custom startup script's `PATH`.
|
||||
|
||||
Managed installs pin the validated Node executable in the `paperclipai` shim
|
||||
and prepend its directory to `PATH` for child tools, including ACP servers with
|
||||
an `/usr/bin/env node` shebang. Re-run the installer using the supported
|
||||
Node runtime after changing runtime installations, then restart the service.
|
||||
For example, put the supported Node's bin directory first on `PATH` and run
|
||||
`npx paperclipai@latest install --yes`. Do not use the old managed shim to
|
||||
re-pin Node: it intentionally continues launching its previously pinned runtime.
|
||||
Installs and updates refresh existing managed shims in place. Updates reject an
|
||||
unsupported running Node before installing or activating a payload; read-only
|
||||
update checks and rollback remain available for recovery. Global npm installs and
|
||||
source checkout services must configure their own executable and child-process `PATH`.
|
||||
|
||||
For custom service wrappers, use an absolute, supported Node executable and put
|
||||
that executable's directory first on `PATH`. Keep required existing PATH entries.
|
||||
On Linux, verify the running executable with `/proc/<server-pid>/exe`; an
|
||||
interactive shell version check alone is insufficient. Use the guarded restart
|
||||
procedure in [DEVELOPING.md](DEVELOPING.md#hot-restart-deploys) when jobs are active.
|
||||
|
||||
Legacy local adapters default to ACP, including configurations with no `engine`
|
||||
field or the old `auto` value. An unavailable ACP runtime fails the run and the
|
||||
agent environment test with a setup error; it never silently changes engines.
|
||||
Repair the reported prerequisite or explicitly select `engine: cli`. Local
|
||||
filesystem/network confinement and in-place Codex workspaces require explicit
|
||||
CLI selection. CLI sandbox defaults and explicit restrictions are described in
|
||||
the adapter configuration documentation.
|
||||
|
||||
## Managed Install Layout
|
||||
|
||||
Managed code is separate from instance data:
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
|
|||
Work is not done until the user can see the result: file, document, preview link, screenshot, plan, or PR.
|
||||
|
||||
6. **Execution visibility without log worship**
|
||||
Active runs, recovery issues, productivity review states, blockers, and work products should be first-class surfaces. Raw transcripts are available when needed, but they are not the primary product surface.
|
||||
Active runs, recovery issues, blockers, and work products should be first-class surfaces. Raw transcripts are available when needed, but they are not the primary product surface.
|
||||
|
||||
7. **Local-first, cloud-ready**
|
||||
The mental model should not change between local solo use and shared/private or public/cloud deployment.
|
||||
|
|
@ -160,3 +160,35 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
|
|||
|
||||
9. **Thin core, rich edges**
|
||||
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.
|
||||
|
||||
### Experimental iMessage Photon channel
|
||||
|
||||
A Photon Cloud project can represent one agent through the existing
|
||||
experimental channel subsystem. DMs and explicitly enabled groups create or
|
||||
continue task-bound conversations. Linked sender identity is the default;
|
||||
telephone numbers, email addresses, names, and group membership do not grant
|
||||
Paperclip authority. Photos/files and ordinary questions/confirmations use the
|
||||
existing attachment, interaction, continuation, and publication contracts.
|
||||
Pause and Disconnect govern runtime behavior independently of the UI gate.
|
||||
Local Mac access, unsolicited conversations, and SMS/RCS
|
||||
fallback are excluded. Live qualification is required before release readiness.
|
||||
Pro shared allocation supports DMs only, with sender enrollment in Photon and
|
||||
separate identity linking in Paperclip. Shared channels reserve one project, not
|
||||
a pool phone number; group admission and publication are disabled. Dedicated
|
||||
allocation retains one selected number and individually enabled groups.
|
||||
|
||||
See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation
|
||||
contract, setup, recovery, boundaries, and qualification status.
|
||||
### Experimental persistent agent conversations
|
||||
|
||||
Agent Chat is an opt-in core task presentation (`enableAgentChat`, off by default). Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
|
|
|||
|
|
@ -335,3 +335,82 @@ 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` runs `Verify Paperclip Runner` on two independent runners.
|
||||
The protocol lane runs `check:eval-kernel` and `check:protocol`. The Rust lane
|
||||
runs `check:runner` and `check:api-authority`. Together they retain every check
|
||||
in `check:all`; both lanes must pass before Cloud source verification or
|
||||
readiness can succeed. A failed lane does not cancel the other lane.
|
||||
|
||||
Both lanes restore Cargo dependencies with the pinned Rust Cache action. The
|
||||
compiler comes from the Runner package's `rust-toolchain.toml` before the action
|
||||
computes its key. Compiler and Cargo metadata changes select a new cache. The
|
||||
existing `release-runner-v1` shared key avoids separate copies for these lanes.
|
||||
Only the Rust lane saves this cache. After verification it also runs `build:rust`
|
||||
to warm the debug dependencies used by the protocol lane; its own tests already
|
||||
warm release dependencies. The cache writer is shorter than the protocol lane.
|
||||
|
||||
Workspace crates and installed Cargo binaries are excluded. Every run rebuilds
|
||||
workspace code and runs all assigned checks, including on a cache hit. Only an
|
||||
own-repository master-push run verifying that push's exact SHA can restore the
|
||||
cache, and only a successful Rust lane saves it. PR, tag, and manual candidate
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## Cloud readiness runner placement
|
||||
|
||||
When AWS routing is enabled, Cloud image builds use `paperclip-cloud-build-x64`
|
||||
and source verification uses `paperclip-post-merge-x64`. The artifact wait and
|
||||
the `Cloud source verified v1` and `Cloud deployable v1` marker jobs run on
|
||||
GitHub-hosted runners. These small jobs must not hold or wait for capacity in
|
||||
the source-verification fleet. During a merge
|
||||
burst, even a completed build must wait for its marker before consumers can
|
||||
recognize readiness.
|
||||
|
||||
Runner placement does not change readiness requirements: exact-source artifacts,
|
||||
all source checks, and the image verification must still pass. The versioned
|
||||
markers and their dependency gates are unchanged.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
# Task search relevance
|
||||
|
||||
## Product rubric
|
||||
|
||||
Search should help someone reopen work they remember, using whatever fragment
|
||||
stuck in memory: an ID, a few title words, a technical name, or something in the
|
||||
conversation. The first screen should contain plausible answers, with enough
|
||||
context to explain each match.
|
||||
|
||||
| Intent | Good result | Failure |
|
||||
|---|---|---|
|
||||
| Known task ID | Exact ID first, case-insensitive; accept `PAP-42`, `pap42`, `PAP 42` | A mention or neighboring ID beats the task |
|
||||
| Remembered title | Exact title, phrase, then all title words in any order | A recent comment mentioning those words beats the title |
|
||||
| Several concepts | Every meaningful query term contributes, including short terms such as API/UI | A task matches only one common word |
|
||||
| Exact phrase | Quoted text stays together and literal | Quotes silently behave like OR or fuzzy search |
|
||||
| Thread memory | Find words across task text, comments and current documents | Relevant content exists but the task cannot be found |
|
||||
| Technical text | Preserve underscores, percent signs, paths and numbers | SQL wildcard expansion or fuzzy IDs return unrelated work |
|
||||
| Typo | Conservative title-word correction; all other terms still required | Ignoring a short term changes the query's meaning |
|
||||
| Result explanation | Show the best evidence and link to its source | A title hit jumps into an unrelated comment |
|
||||
| Old work | Strong completed-task matches remain ahead of weak recent hits | Recency/activity replaces relevance |
|
||||
| Boundaries | Company, visibility, deletion and explicit filters always apply | Content leaks through counts, snippets or typo matches |
|
||||
| Operations | PostgreSQL only, synchronous current-row reads, bounded query/page sizes | A worker, remote index or eventual-consistency repair is required |
|
||||
|
||||
Judge results on a 0–3 scale: **3** directly answers the remembered task intent,
|
||||
**2** is useful related work, **1** is only an incidental mention, **0** is
|
||||
irrelevant. Ambiguous short queries may have several grade-3 answers; do not
|
||||
invent a unique intended task for them.
|
||||
|
||||
Acceptance gates:
|
||||
|
||||
- Every unambiguous known-task case returns its intended task first.
|
||||
- Every grade-3 result in the small judged corpus appears in the first five.
|
||||
- All explicit negative, visibility, filter, freshness and literal-query cases pass.
|
||||
- Report mean reciprocal rank (first grade-3 result) and nDCG@5 (graded ordering
|
||||
and recall). Target MRR ≥ 0.95 and nDCG@5 ≥ 0.90 on the authored corpus.
|
||||
- Measure both the full search page and the command-palette/task-list API.
|
||||
- Measure database-backed latency separately from relevance. Report dataset
|
||||
size, warm/cold assumptions and hardware; a small fixture is not scale proof.
|
||||
Initial target: warm p95 ≤ 250 ms at 10,000 tasks and 30,000 short comments.
|
||||
A regression greater than 20% from baseline requires investigation and an
|
||||
explicit explanation of the cost; do not describe a quality improvement as
|
||||
latency-neutral when it is not.
|
||||
|
||||
The initial corpus is synthetic and deliberately adversarial. It includes
|
||||
plausible distractors and gives older completed tasks strong relevance labels.
|
||||
It is not evidence that every real user's search is solved. Add real failed
|
||||
queries and human judgments as they become available. Do not adjust judgments
|
||||
just to improve a score.
|
||||
|
||||
## Previous behavior
|
||||
|
||||
The command palette calls the issue-list endpoint. It searched one literal
|
||||
substring across title, identifier, description and comments, prioritized titles
|
||||
before identifiers, and did not search documents or recover typos. Reordered
|
||||
words commonly returned no result.
|
||||
|
||||
Company search used a different algorithm: any token admitted a result, bonuses
|
||||
from titles, comments and documents accumulated, and title-only token coverage
|
||||
was indistinguishable from words scattered across a long thread. It ran edit
|
||||
distance for title words, discarded short terms from fuzzy matching, and also
|
||||
fuzzed identifiers. Quotes were tokenized but did not constrain other matches.
|
||||
|
||||
## Matching contract
|
||||
|
||||
Both task search paths use `server/src/services/task-search.ts`. Search is lexical:
|
||||
trim/collapse whitespace, normalize case, keep quoted phrases, remove a small
|
||||
set of unquoted grammatical filler words, deduplicate terms, and retain up to
|
||||
8 terms within the existing 200-character query bound. All-filler queries keep
|
||||
their terms. No synonym service, embedding model or language-specific stemming
|
||||
is involved.
|
||||
|
||||
All retained terms must match. Full search and task lists allow terms to occur
|
||||
across task text and current, undeleted conversation/document content. The
|
||||
Tasks scope requires coverage in task text. Comments and Documents require a
|
||||
participating match in that source, while retaining the task context. Exact/prefix
|
||||
identifiers and conservative title-word typo matches are additional task matches.
|
||||
Typo matching runs only when no literal match satisfies the requested filters.
|
||||
It never guesses task numbers, loosens a quoted phrase or drops a short query
|
||||
term. Alphabetic terms of one to three characters must begin a word, so `UI`
|
||||
does not match `build`, while incomplete longer words still support typeahead.
|
||||
|
||||
Ranking uses disjoint bands: exact ID, ID prefix, exact title, title phrase,
|
||||
all title terms, all task-text terms, all thread terms, then title typo recovery.
|
||||
Whole-word title matches and title prefixes break close ties; status has only a
|
||||
small effect within a band. Full search uses recency and stable IDs for remaining
|
||||
ties; task lists retain their existing priority/activity tie-breaking. Explicit
|
||||
created/updated/priority sort modes retain their documented behavior. Other
|
||||
entity types retain their existing scoring rules, rescaled to keep exact names
|
||||
ahead of speculative task typo matches. The UI displays the server's order
|
||||
without regrouping results by source.
|
||||
|
||||
The existing `pg_trgm` indexes support literal substring retrieval. Tagged
|
||||
comment/document match sets are computed once per search with separate indexed
|
||||
patterns. Ranking stages carry compact flags; descriptions and matching snippets
|
||||
are fetched for the result window. The database reads current rows, so creates, edits, deletions and
|
||||
hidden-task changes take effect without indexing jobs. Bounded edit-distance
|
||||
checks operate on titles only, run only as a zero-result fallback, and guard
|
||||
fuzzystrmatch's 255-character argument limit. There is no schema migration or
|
||||
new extension in this change.
|
||||
|
||||
PostgreSQL documents the existing index support in
|
||||
[pg_trgm](https://www.postgresql.org/docs/17/pgtrgm.html).
|
||||
|
||||
## Reproduce the evaluation
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
|
||||
# Also write per-query rankings and metrics for inspection:
|
||||
SEARCH_EVAL_REPORT=/tmp/search-quality.json pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
|
||||
# Include the larger latency dataset and query plans:
|
||||
SEARCH_EVAL_SCALE=1 SEARCH_EVAL_REPORT=/tmp/search-scale.json pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
|
||||
```
|
||||
|
||||
The fixture is `server/src/__tests__/fixtures/task-search-corpus.ts`. Tests run
|
||||
the real services against a temporary embedded PostgreSQL database with the
|
||||
normal migrations. `SEARCH_EVAL_BASELINE=1` records judgments without asserting
|
||||
improved behavior. To compare another revision, copy this test, its fixture and
|
||||
`task-search.ts` into a separate worktree for that revision, leave its actual
|
||||
`company-search.ts` and `issues.ts` services unchanged, and run with
|
||||
`SEARCH_EVAL_BASELINE=1`. The copied helper is not used by the baseline services;
|
||||
its query-plan branch is disabled in baseline mode.
|
||||
|
||||
## Initial evaluation — 2026-09-12
|
||||
|
||||
Compared against `2083bf6f9` using the same 31-task corpus and 24 queries (23
|
||||
queries with intended answers, plus one no-result query).
|
||||
|
||||
| Surface | Intended answer first, before → after | MRR, before → after | nDCG@5, before → after |
|
||||
|---|---|---|---|
|
||||
| Full search | 17/23 → 23/23 | 0.828 → 1.000 | 0.904 → 0.999 |
|
||||
| Quick search / task list | 5/23 → 23/23 | 0.268 → 1.000 | 0.339 → 0.999 |
|
||||
|
||||
The relevance gates pass. These results measure the authored corpus, not general
|
||||
search accuracy. The no-result query also returns no tasks in both surfaces.
|
||||
|
||||
The scale run adds 10,000 tasks with ~300-character descriptions and 30,000
|
||||
~345-character comments. Measurements call the real service methods (including
|
||||
facets/snippets or task-list hydration), excluding HTTP and UI debounce. Each
|
||||
query has one separately recorded first request and 20 warm repetitions; p95
|
||||
is the 19th sorted warm sample. This is not a cold-disk test. Both revisions used
|
||||
PostgreSQL 18.1, default planner/memory settings, and `ANALYZE` after seeding.
|
||||
The host was an Apple M5 Max with 128 GiB RAM, running an x86_64 PostgreSQL
|
||||
binary and other development tests concurrently. Treat timing deltas as local
|
||||
measurements, not production capacity or a controlled concurrency benchmark.
|
||||
|
||||
| Query | Full p95 before → after (ms) | Quick p95 before → after (ms) |
|
||||
|---|---|---|
|
||||
| `GitHub OAuth` | 139 → 95 | 39 → 128 |
|
||||
| `OAuth callback GitHub` | 209 → 81 | 26 → 134 |
|
||||
| `mibile api` | 153 → 131 | 19 → 111 |
|
||||
| `search` | 172 → 41 | 22 → 67 |
|
||||
| `quasarxylophone` | 154 → 105 | 18 → 218 |
|
||||
| `routine` (matches all 10,000 added tasks) | 239 → 253 | 152 → 371 |
|
||||
|
||||
Selective full searches improved. Quick search is more expensive: it now
|
||||
evaluates term coverage, searches documents, and can scan company titles for
|
||||
typo recovery. The old quick search returned no answers for the reordered and
|
||||
typo queries, so its lower cost did not deliver equivalent results. The relative
|
||||
regression threshold is triggered, and broad-query p95 does **not** meet the
|
||||
initial 250 ms target. This is an explicit performance limitation of this pass.
|
||||
The implementation adds no operational service, but it is not latency-neutral.
|
||||
|
||||
`EXPLAIN (ANALYZE, BUFFERS)` confirmed existing trigram indexes on selective
|
||||
comment/document retrieval and zero fuzzy-branch executions for successful
|
||||
literal searches. Removing descriptions from intermediate materialized rows
|
||||
eliminated 1,699 temporary blocks (~13 MiB) of writes in the broad-query core
|
||||
plan; its final measured execution was 139 ms with no temporary writes. The
|
||||
quick-search endpoint also performs its existing activity sorting and task
|
||||
hydration. Larger companies, long threads and sustained concurrent searches
|
||||
still need production-shaped measurement before a stronger latency claim.
|
||||
|
||||
Browser acceptance used the real built app against an isolated PostgreSQL
|
||||
database containing this corpus. Starting from the dashboard, the Search link
|
||||
found an older completed task from reordered title words and opened that task.
|
||||
Command-K ranked `PAP-42` first for `pap42`; `mibile api` recovered only the
|
||||
intended mobile API task and carried the query into full search. Quoted
|
||||
`"connection timeout"` excluded scattered words. `Hermes parser` showed the
|
||||
document title as evidence and opened the correct plan in the task's side panel.
|
||||
The desktop result layout was visually inspected. Mobile layout, continuous
|
||||
transition timing and production data were not part of this walkthrough.
|
||||
|
||||
For a future failed search, record the query, what the person remembered, and
|
||||
the intended task IDs. Grade the old top five and any missed intended tasks
|
||||
before changing the ranker, add realistic distractors, then run both entry
|
||||
points. Keep these judgments independent of the ranking constants.
|
||||
|
||||
Verification: 78 search/parser tests (including the real PostgreSQL scale run),
|
||||
14 existing task-list search/filter tests, and 29 Search/CommandPalette UI tests
|
||||
passed. Workspace typecheck, the final server typecheck, production build,
|
||||
Storybook build and token gates passed. The full repository test run was stopped
|
||||
after `chat-channels.integration.test.ts` reported one failure in “publishes a
|
||||
closed-choice question, settles its Slack card, and delivers its exact
|
||||
continuation response”; that test passed when rerun alone. The remaining broad
|
||||
suite was not completed, so this is not a claim of a green repository-wide run.
|
||||
|
||||
The API response contracts and company authorization stay unchanged. Artifact,
|
||||
agent and project ranking are separate from the task relevance rubric. Extraction
|
||||
search and the specialized blocked-attention queue retain their existing
|
||||
literal matching. Pure semantic paraphrases and
|
||||
language-specific word inflections are outside this first lexical rubric.
|
||||
|
|
@ -21,6 +21,9 @@ Paperclip V1 must provide a full control-plane loop for autonomous agents:
|
|||
4. All work is tracked through tasks/comments with audit visibility.
|
||||
5. Token/cost usage is reported and budget limits can stop work.
|
||||
6. The board can intervene anywhere (pause agents/tasks, override decisions).
|
||||
An effective task or ancestor pause replaces the message composer with an
|
||||
amber Resume takeover. New board messages, including updates with comments,
|
||||
are rejected until the hold is released. Drafts survive pause and resume.
|
||||
|
||||
Success means one operator can run a small AI-native company end-to-end with clear visibility and control.
|
||||
|
||||
|
|
@ -38,7 +41,7 @@ These decisions close open questions from `SPEC.md` for V1.
|
|||
| Communication | Tasks + comments only (no separate chat system) |
|
||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: continue interrupted local conversations with bounded fresh turns and preserved history, never replay tool calls automatically; retain native ownership and real execution gates; preserve verified stop evidence and reconsider saved post-stop user messages after cleanup; otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
|
||||
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
|
||||
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
||||
|
|
@ -216,6 +219,14 @@ Invariant:
|
|||
|
||||
Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent.
|
||||
|
||||
Project source repositories use the existing `project_workspaces` collection.
|
||||
Each selected GitHub repository has a canonical `repo_url` and stable provider ID
|
||||
in `metadata.githubRepositoryId`; the first workspace remains the execution default.
|
||||
The board can select multiple repositories from its usable personal and shared
|
||||
GitHub grants. Selection does not grant runtime credential access. Legacy workspace
|
||||
URLs remain valid. Project creation and repository replacement are transactional.
|
||||
See `doc/project-repositories.md` for the API and UI contract.
|
||||
|
||||
## 7.6 `issues` (core task entity)
|
||||
|
||||
- `id` uuid pk
|
||||
|
|
@ -236,6 +247,9 @@ Routine execution issues add a routine-scoped env overlay after project env and
|
|||
- `created_by_user_id` uuid fk `users.id` null
|
||||
- identifier fields: `issue_number`, `identifier`
|
||||
- origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint`
|
||||
- Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped.
|
||||
- Relation lists can use `sortField=id&sortDir=asc&afterId=<uuid>` for stable pagination. The cursor excludes earlier IDs and cannot be combined with an offset or activity-based order.
|
||||
- The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks.
|
||||
- `request_depth` int not null default 0
|
||||
- `work_mode` text not null default `standard`; supported values:
|
||||
- `standard`: normal autonomous execution. Agents may investigate, edit files, create artifacts, and complete the task.
|
||||
|
|
@ -414,6 +428,7 @@ Operational policy:
|
|||
- Default upload allowlist includes common images, PDF, plain text/markdown/JSON/CSV/HTML, ZIP, and video artifacts (`video/mp4`, `video/webm`, `video/quicktime`).
|
||||
- Attachment reads are company-scoped and expose stable path metadata: `contentPath`/`openPath` for inline-safe viewing and `downloadPath` for forced download.
|
||||
- Inline-safe responses use `Content-Disposition: inline`; unsafe types and explicit download requests use `attachment`.
|
||||
- Script-capable content such as HTML is always served as an attachment with `X-Content-Type-Options: nosniff` and a sandboxed, deny-by-default CSP; it is never rendered inline on the Paperclip origin.
|
||||
- Video attachments are inline-safe and support single `Range: bytes=start-end` requests with `206`, `Content-Range`, and `Accept-Ranges: bytes` for browser playback/seeking.
|
||||
- Attachment-backed artifact work products use `type: "artifact"`, `provider: "paperclip"`, and metadata with `attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, `downloadPath`, and optional `originalFilename`.
|
||||
- Workspace-only file references use work product `metadata.resourceRef` with `kind: "workspace_file"`, `issueId`, `workspaceKind` (`execution_workspace` or `project_workspace`), `workspaceId`, `relativePath`, optional `line`/`column`, and `displayPath`. These references point at files in a workspace; they do not replace attachment-backed artifacts for deliverables that must be inspectable without workspace access.
|
||||
|
|
@ -1030,6 +1045,17 @@ instances return `404`.
|
|||
- `GET /issues/:issueId/attachments`
|
||||
- `GET /attachments/:attachmentId/content`
|
||||
- `DELETE /attachments/:attachmentId`
|
||||
- `GET /issues/:issueId/runner-goal?agentId=...`
|
||||
- `POST /issues/:issueId/runner-goal/actions`
|
||||
|
||||
The runner-goal endpoints control an issue-scoped durable agent-session goal,
|
||||
not a row in the company `goals` hierarchy. Reads return the effective agent,
|
||||
negotiated capability, normalized goal snapshot, active-run state, pending
|
||||
action, and revision. Mutations require a request id, assigned agent, expected
|
||||
revision, and a negotiated action; they return `202`, replay the original result
|
||||
for a duplicate request id, and return `409` with the current projection for a
|
||||
stale revision or an unconfirmed unfinished-goal replacement. These controls do
|
||||
not create issue comments.
|
||||
|
||||
### 10.4.1 Atomic Checkout Contract
|
||||
|
||||
|
|
@ -1116,6 +1142,9 @@ The current app also exposes V1-supporting surfaces for:
|
|||
- company-scoped summary slots for projects, the workspaces overview, project workspaces, and individual execution workspaces; execution-workspace slots are keyed by execution workspace id so a new workspace never inherits another workspace's summary
|
||||
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) with the open-default resolver contract in §9.8.1
|
||||
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
|
||||
- task search uses shared PostgreSQL matching/ranking for company search and task-list quick search;
|
||||
all query terms contribute, quoted phrases stay literal, exact identifiers and direct title matches
|
||||
lead relevance ordering, and the UI preserves server result order (see `doc/SEARCH.md`)
|
||||
- company search through `GET /companies/:companyId/search` plus agent-oriented bulk extraction through
|
||||
`GET /companies/:companyId/search/extract`; extraction accepts a server-escaped literal `contains`, optional
|
||||
server-owned URL expansion, issue/comment/document scopes, status/date filters, issue-level pagination, a
|
||||
|
|
@ -1156,6 +1185,17 @@ interface AgentAdapter {
|
|||
}
|
||||
```
|
||||
|
||||
### Local adapter engine availability
|
||||
|
||||
For the legacy Codex, Claude, Gemini, and Kimi local adapters, an omitted engine
|
||||
or legacy `auto` value selects ACP deterministically. Missing prerequisites or
|
||||
ACP execution failures fail the run; they must not launch a different engine
|
||||
with different session, permission, or sandbox semantics. CLI execution requires
|
||||
explicit selection. Environment tests report the same engine availability error
|
||||
as execution. Codex CLI defaults permit workspace writes and network access for
|
||||
Paperclip coordination without disabling its sandbox; explicit operator
|
||||
restrictions and execution-target network denials remain effective.
|
||||
|
||||
## 11.2 Process Adapter
|
||||
|
||||
Config shape:
|
||||
|
|
@ -1224,6 +1264,40 @@ Scheduler must skip invocation when:
|
|||
- an existing run is active
|
||||
- hard budget limit has been hit
|
||||
|
||||
Legacy execution records a renewable controller lease when claiming a queued run,
|
||||
before provisioning. A live lease protects the run during overlapping service
|
||||
deployments. An expired controller loses dispatch authority; a recovery worker
|
||||
must establish that the previous execution stopped before starting a successor.
|
||||
|
||||
## 11.7 Durable agent session goals
|
||||
|
||||
Runner Protocol v2 negotiates a required `sessionGoals` capability and typed
|
||||
`session.goal.*` commands and events. PRP v1 sessions remain supported and are
|
||||
goal unsupported. The Codex app-server driver maps controls to
|
||||
`thread/goal/get`, `thread/goal/set`, and `thread/goal/clear`; it observes
|
||||
provider-created goal notifications and reconciles with an authoritative get
|
||||
after each turn. An active goal suppresses premature run terminalization while
|
||||
autonomous turns continue. The Paperclip runner's persistent ACP backend opts
|
||||
in through the `_session/goal` extension and advertises its exact action subset.
|
||||
Its pinned Codex/Claude executables retain the runner's Linux x64 qualification
|
||||
requirement. Direct `codex_local` and `claude_local` adapters currently have no
|
||||
live goal controller and remain unsupported, even when their underlying ACP
|
||||
package exposes goals. Goal actions never change an agent's adapter, model,
|
||||
permission policy, or rollout settings to manufacture support. CLI, one-shot
|
||||
ACP, and providers without the structured extension remain unsupported.
|
||||
|
||||
When a goal heartbeat settles, the runner suspends its durable authority even
|
||||
under a warm lifecycle policy. Paused, blocked, completed, and rollover goals
|
||||
must survive controller restart without relying on an in-memory warm owner.
|
||||
The next run resumes the same provider session through the existing verified
|
||||
checkpoint and authority-rotation path.
|
||||
|
||||
The board composer treats `/goal` as an action command rather than Markdown or
|
||||
comment text. It is capability-aware, and the issue thread renders durable goal
|
||||
status and controls immediately above the composer. Goal completion enters the
|
||||
normal run-result/completion arbitration path and does not directly close the
|
||||
issue.
|
||||
|
||||
## 12. Governance and Approval Flows
|
||||
|
||||
## 12.1 Hiring
|
||||
|
|
@ -1252,6 +1326,22 @@ Board can at any time:
|
|||
- edit budgets and limits
|
||||
- approve/reject/cancel pending approvals
|
||||
|
||||
## 12.4 Connection Tool Reviews
|
||||
|
||||
Ask-first connection calls use a server-owned tool-action confirmation linked to
|
||||
the authoritative action request. The task feed retains a stable record; dismissal
|
||||
only hides the composer takeover. Task and Connections decisions share one
|
||||
transaction. Approval runs stored, signed arguments once; decline runs nothing.
|
||||
The human decision remains distinct from provider execution success or failure.
|
||||
|
||||
Always allow remembers the same agent, connection, and action, restricted to the
|
||||
current project when present, with future argument values permitted. Explicit
|
||||
denials, revoked access, catalog-definition changes, and formal approval gates
|
||||
remain effective. A durable continuation receipt resumes eligible task context
|
||||
with the recorded outcome after the agent yields. Uncertain interrupted execution
|
||||
is surfaced without automatic replay. See [Task reviews](connections/TASK-REVIEWS.md)
|
||||
for contracts, recovery behavior, Storybook, and acceptance workflows.
|
||||
|
||||
## 13. Cost and Budget System
|
||||
|
||||
## 13.1 Budget Layers
|
||||
|
|
@ -1353,6 +1443,10 @@ Required UX behaviors:
|
|||
- CSRF protection for board session endpoints
|
||||
- rate limit auth and key-management endpoints
|
||||
- strict company boundary checks on every entity fetch/mutation
|
||||
- restricted `skill_test` and `task_bridge` keys cannot enumerate company-wide run telemetry, workspace-operation logs, or the company secret catalog
|
||||
- HTTP adapters use DNS-pinned outbound requests, reject redirects and link-local/metadata targets, and require an exact server-owner origin allowlist for private destinations
|
||||
- external instruction bundle roots and exports that read them require instance-admin access; managed company-scoped bundles remain available through normal company authorization
|
||||
- agent-authenticated callers cannot persist host-executed workspace commands, and restricted keys cannot invoke preconfigured workspace runtime controls
|
||||
|
||||
## 17. Testing Strategy
|
||||
|
||||
|
|
@ -1481,3 +1575,92 @@ Export/import behavior in V1:
|
|||
- import supports preview (dry-run) before apply
|
||||
- import preview reports skill-policy and legacy-grant mappings before apply and rejects unknown policy schema versions
|
||||
- GitHub imports warn on unpinned refs instead of blocking
|
||||
|
||||
### Experimental task-backed agent chat (2026-09-10)
|
||||
|
||||
`enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected.
|
||||
|
||||
`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue on first send/upload. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window.
|
||||
|
||||
The server owns conversation state: `waiting` plus `in_review` denotes a healthy idle conversation, and `active` denotes an unanswered or executing turn. Successful replies settle a turn; they do not finish the issue. Idle containers are excluded from execution-work counts, ordinary task lists, timer work, and recovery invocations. Failed/unanswered turns retain normal handling. Child completion never wakes or completes the conversation. Search and direct task access preserve history.
|
||||
|
||||
Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider.
|
||||
|
||||
Chat prompts retain agent instructions and tools while directing clarification and task creation. Substantial execution belongs to linked, assigned ordinary issues. Ask mode remains non-mutating. Feature disablement prevents new turns and resets while retaining data and lifecycle protection; already-running turns may settle normally.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
### User continuation after execution recovery stops
|
||||
|
||||
An authenticated user message or an exact failed-run Retry can start a fresh
|
||||
native or legacy conversation turn once the prior execution is confirmed stopped. Retain the source history and uncertain
|
||||
action outcomes; do not replay tool calls or reset the failed incident's automatic
|
||||
retry budget. Existing pause, approval, budget, ownership, and dependency gates
|
||||
remain in effect. See `doc/execution-semantics.md` for admission and stop-proof
|
||||
requirements.
|
||||
|
||||
### Managed AI authentication
|
||||
|
||||
AI credentials can be adopted into the existing Connections system. A typed
|
||||
`runtimeConfig.aiConnection` selects the responsible user’s personal default, an
|
||||
explicit shared grant. The existing human-audience and agent-access permissions
|
||||
apply; AI credentials have no separate agent-delegation exception. Selection preserves
|
||||
harness/model routing and fails closed without ambient credential fallback.
|
||||
Legacy agents retain their authentication until validated adoption. See
|
||||
[AI Connections](connections/AI-CONNECTIONS.md) for company isolation, compatible
|
||||
methods, lifecycle, runtime enforcement, and migration details.
|
||||
### Experimental task-bound email
|
||||
|
||||
AgentMail channel connections extend the experimental conversation/task pipeline
|
||||
with explicit email publication. Each owned inbox/provider thread binds one task;
|
||||
external email senders do not gain board authority. Incoming correspondence uses
|
||||
the assigned agent's normal execution controls. Internal task activity never
|
||||
implicitly sends email. New outgoing conversations create child tasks and durable
|
||||
send intents before provider contact. The board directs email work through the
|
||||
normal task conversation; rich email cards show the correspondence and delivery
|
||||
outcomes without a separate email composer. See
|
||||
[AgentMail connections](connections/AGENTMAIL.md) for setup, transports, recovery,
|
||||
authorization, and the API/CLI contract.
|
||||
|
||||
### Experimental iMessage Photon channel
|
||||
|
||||
A Photon Cloud project can represent one agent through the existing
|
||||
experimental channel subsystem. DMs and explicitly enabled groups create or
|
||||
continue task-bound conversations. Linked sender identity is the default;
|
||||
telephone numbers, email addresses, names, and group membership do not grant
|
||||
Paperclip authority. Photos/files and ordinary questions/confirmations use the
|
||||
existing attachment, interaction, continuation, and publication contracts.
|
||||
Pause and Disconnect govern runtime behavior independently of the UI gate.
|
||||
Local Mac access, unsolicited conversations, and SMS/RCS
|
||||
fallback are excluded. Live qualification is required before release readiness.
|
||||
Pro shared allocation supports DMs only, with sender enrollment in Photon and
|
||||
separate identity linking in Paperclip. Shared channels reserve one project, not
|
||||
a pool phone number; group admission and publication are disabled. Dedicated
|
||||
allocation retains one selected number and individually enabled groups.
|
||||
|
||||
iMessage task completion ends a turn, not its conversation. Subsequent messages
|
||||
reopen the same task, including after restart; only explicit `/new` or `/close`
|
||||
allows the next message to start another task. The open task receives committed
|
||||
inbound comments live, with “Sent from iMessage” attribution on user bubbles.
|
||||
|
||||
See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation
|
||||
contract, setup, recovery, boundaries, and qualification status.
|
||||
|
||||
### Native task completion
|
||||
|
||||
For ordinary low-risk tasks, accept the current agent's structured `done` claim
|
||||
subject to explicit workflow constraints. Missing independent evidence or a
|
||||
`needs_review` label alone must not create a human approval. Require a concrete
|
||||
reviewer decision for a new review request. Keep unfinished work with the agent,
|
||||
with bounded continuation and visible recovery. Preserve explicit approvals,
|
||||
current task ownership, cancellation, dependencies, and newer task state. See
|
||||
`doc/architecture/native-status-arbitration.md` for finish feedback and the
|
||||
provenance-checked cleanup of historical automatic completion reviews.
|
||||
|
|
|
|||
63
doc/SPEC.md
63
doc/SPEC.md
|
|
@ -29,6 +29,11 @@ Every Company has a **Board** that governs high-impact decisions. The Board is t
|
|||
- CEO's initial strategic breakdown (CEO proposes, Board approves before execution begins)
|
||||
- [TBD: other governance-gated actions — goal changes, firing Agents?]
|
||||
|
||||
Connection tool reviews also appear in task history, with a composer takeover for
|
||||
human approval, decline, or scoped remembered permission. Connections and task
|
||||
views resolve the same review, and the agent continues with the server-recorded
|
||||
outcome. See [the implementation contract](SPEC-implementation.md#124-connection-tool-reviews).
|
||||
|
||||
#### Board Powers (Always Available)
|
||||
|
||||
The Board has **unrestricted access** to the entire system at all times:
|
||||
|
|
@ -171,6 +176,11 @@ When a task originates from a cross-team request, track the **depth** as an inte
|
|||
|
||||
#### Billing Codes
|
||||
|
||||
Task detail keeps hierarchy separate from creation provenance: the Tasks tab shows
|
||||
all subtasks and, independently, work created from the current task grouped by
|
||||
project or No project. A created subtask may appear in both sections. Creation
|
||||
provenance follows the originating run equally for legacy and native runners.
|
||||
|
||||
Tasks carry a **billing code** so that token spend during execution can be attributed upstream to the requesting task/agent. When Agent A asks Agent B to do work, the cost of B's work is tracked against A's request. This enables cost attribution across the org.
|
||||
|
||||
### Open Questions
|
||||
|
|
@ -204,6 +214,12 @@ Agent configuration includes an **adapter** that defines how Paperclip invokes t
|
|||
|
||||
The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||
|
||||
An adapter's selected execution engine is part of its permission and session
|
||||
contract. Missing prerequisites or engine failures must be surfaced without
|
||||
silently launching a different engine. A default local engine must support
|
||||
normal task work and control-plane coordination; explicit operator restrictions
|
||||
remain authoritative.
|
||||
|
||||
### Adapter Interface
|
||||
|
||||
Every adapter implements three methods:
|
||||
|
|
@ -261,6 +277,8 @@ All agent communication flows through the **task system**.
|
|||
|
||||
There is no separate messaging or chat system. Tasks are the communication channel. This keeps all context attached to the work it relates to and creates a natural audit trail.
|
||||
|
||||
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
|
||||
|
||||
### Implications
|
||||
|
||||
- An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in
|
||||
|
|
@ -532,3 +550,48 @@ Things Paperclip explicitly does **not** do:
|
|||
7. **Atomic ownership.** Single assignee per task. Atomic checkout prevents conflicts.
|
||||
8. **Progressive deployment.** Trivial to start local, straightforward to scale to hosted.
|
||||
9. **Extensible core.** Clean boundaries so plugins can add capabilities (Adapters, knowledge base, revenue tracking) without modifying core.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
### Paused task messages
|
||||
|
||||
A paused task takes over the composer with an amber notice and a Resume action.
|
||||
Operators must release the effective task or ancestor pause before sending a new
|
||||
message. The draft stays intact. This applies to both task interfaces and to
|
||||
board comment API requests; an agent may still report interrupted work.
|
||||
|
||||
### Experimental iMessage Photon channel
|
||||
|
||||
A Photon Cloud project can represent one agent through the existing
|
||||
experimental channel subsystem. DMs and explicitly enabled groups create or
|
||||
continue task-bound conversations. Linked sender identity is the default;
|
||||
telephone numbers, email addresses, names, and group membership do not grant
|
||||
Paperclip authority. Photos/files and ordinary questions/confirmations use the
|
||||
existing attachment, interaction, continuation, and publication contracts.
|
||||
Pause and Disconnect govern runtime behavior independently of the UI gate.
|
||||
Local Mac access, unsolicited conversations, and SMS/RCS
|
||||
fallback are excluded. Live qualification is required before release readiness.
|
||||
Pro shared allocation supports DMs only, with sender enrollment in Photon and
|
||||
separate identity linking in Paperclip. Shared channels reserve one project, not
|
||||
a pool phone number; group admission and publication are disabled. Dedicated
|
||||
allocation retains one selected number and individually enabled groups.
|
||||
|
||||
See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation
|
||||
contract, setup, recovery, boundaries, and qualification status.
|
||||
|
||||
## Task search relevance
|
||||
|
||||
Task discovery uses PostgreSQL and the existing search indexes, with no external
|
||||
search service or background indexing job. The task-list quick search and full
|
||||
company search share lexical matching and ranking. Known identifiers and direct
|
||||
title matches lead; current conversation and document content supplies supporting
|
||||
evidence. See [Task search relevance](SEARCH.md) for the evaluation rubric,
|
||||
matching contract and reproducible quality tests.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
# 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.
|
||||
|
||||
Each publication updates a readable bookmark, such as
|
||||
`https://d1p6rlowie26tp.cloudfront.net/storybook/branches/master/`, after the
|
||||
immutable build upload completes. It also updates the previous hashed branch
|
||||
entry for compatibility. The run summary and Markdown artifact link the bookmark.
|
||||
Branch names use one escaped path segment, preserving case and separating slashes
|
||||
from hyphens; see [the branch publishing guide](DEVELOPING.md#publish-a-branch-storybook)
|
||||
for the encoding. No additional AWS permissions or distribution changes 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.
|
||||
|
|
@ -136,7 +136,8 @@ conditions before model disposition:
|
|||
| Run failed | Preserve | Schedule recovery |
|
||||
| Approval, interaction, or execution stage is pending | `in_review` | Materialize/bind the governance gate and notify its owner |
|
||||
| Completion satisfies its authority policy | `done` | Release checkout |
|
||||
| Runner reports `needs_review` | `in_review` | Bind a reviewer and notify the owner |
|
||||
| Runner reports a concrete attention request with a reviewer and decision | `in_review` | Bind the requested reviewer |
|
||||
| Runner reports `needs_review` without a decision, or an incomplete completion claim | Keep work with the agent | No automatic human approval; at most one corrective continuation, then a visible recovery action |
|
||||
| Runner reports a task-wide blocker | `blocked` | Persist blocker owner and unblock action |
|
||||
| Runner reports a current-track blocker | `in_progress` | Enqueue another productive track |
|
||||
| Runner reports `yielded` with a valid continuation | `in_progress` | Enqueue the declared continuation |
|
||||
|
|
@ -220,6 +221,19 @@ Examples:
|
|||
- a materialization failure records the failed phase and next retry time rather
|
||||
than silently dropping the side effect.
|
||||
|
||||
## Policy upgrades
|
||||
|
||||
The policy version on an assessment is audit metadata. New runs use the current
|
||||
rules. A version change alone does not reassess an old run, change task status,
|
||||
or ask a person to review completion. New evidence and explicit status changes
|
||||
still use the existing reconciliation paths.
|
||||
|
||||
Reconciliation also withdraws pending review cards created solely by the old
|
||||
policy-version check. It restores the previous status only if that exact decision
|
||||
and status version are still current and no other review gate is pending. A later
|
||||
user or agent decision takes precedence. The old assessments and decisions remain
|
||||
in the audit history; cleanup does not accept or reject the agent's work.
|
||||
|
||||
## Diagnosing an unexpected status
|
||||
|
||||
Start with the terminal heartbeat run and inspect:
|
||||
|
|
@ -267,3 +281,33 @@ Common patterns:
|
|||
See also
|
||||
[`durable-continuation-scheduler.md`](./durable-continuation-scheduler.md) for
|
||||
the scheduler and recovery behavior that follows an `in_progress` decision.
|
||||
|
||||
## Explicit completion reviews
|
||||
|
||||
Ordinary task completion uses the agent's structured `done` claim under the
|
||||
contract's low-risk claim policy. Unknown evidence references remain diagnostic
|
||||
information; they do not create human approval requirements. Cancellation,
|
||||
newer task state, unresolved dependencies, and explicit governance still win.
|
||||
|
||||
Paperclip no longer creates a generic "Native completion review" because a
|
||||
report is incomplete, verification failed, or the agent says `needs_review`.
|
||||
A new review interaction requires an explicit attention request naming the
|
||||
reviewer's responsibility and the decision. The card displays that request.
|
||||
Waiting for CI remains agent work, not a human completion approval.
|
||||
|
||||
The native runner returns current approval/dependency constraints to the agent
|
||||
when it calls `paperclip_finish`. An empty `needs_review` report without an
|
||||
existing gate is rejected with instructions to correct it. The final reply must
|
||||
explain any required user action and link to the relevant task or approval.
|
||||
The tool acknowledges receipt, not a premature status commit: final status is
|
||||
committed only after the provider turn and workspace finalization settle.
|
||||
|
||||
On upgrade, bounded cleanup withdraws only unanswered, system-created fallback
|
||||
cards proven by their decision/effect ledger, original prompt/target, empty
|
||||
attention request list, and low-risk claim policy. Explicit or answered reviews
|
||||
and stronger completion policies are preserved. Withdrawal has audit history
|
||||
and retires chat actions. Reconciliation reassesses only the current successful
|
||||
run's result, with the same task status/version and completion contract and no
|
||||
newer execution owner. It applies normal governance and dependency checks and
|
||||
appends a decision; it never marks every affected task done blindly. A persisted
|
||||
withdrawal marker makes restart between cleanup and reassessment retryable.
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
|
|
@ -0,0 +1,261 @@
|
|||
# Cloud build readiness
|
||||
|
||||
The `Cloud readiness` workflow starts for every master push. Its versioned
|
||||
`Cloud deployable v1` job succeeds only after all three prerequisites succeed:
|
||||
|
||||
- The existing `Release Verify` workflow checks that exact commit, including
|
||||
typecheck, builds, general and serialized tests, and Runner verification.
|
||||
- The reusable `Docker cloud` workflow builds and verifies its Linux AMD64
|
||||
image, including Sentry resolution and orphan reaping, then publishes the
|
||||
full-SHA cloud tag. Cloud readiness owns the master trigger so there is one
|
||||
cloud build per push. Release tags and manual Docker runs retain their callers.
|
||||
- The full-SHA image and both exact-source npm packages are visible. The
|
||||
packages are `@paperclipai/shared` and `@paperclipai/db` at
|
||||
`0.0.0-preview.g<FULL_SHA>`, published through the migrator-only release lane.
|
||||
Registry metadata must match the full commit, and the database package must
|
||||
pin the matching shared package.
|
||||
|
||||
The Cloud workflow builds the image with `USER_UID=1001` and `USER_GID=1001`,
|
||||
matching the managed runtime. This avoids a startup user remap, which can walk
|
||||
the mounted home and delay health checks. Before publishing the full-SHA tag,
|
||||
the workflow checks the baked identity without running the entrypoint, then
|
||||
checks the normal entrypoint's effective user and writable home. Volume ownership
|
||||
repair still runs when needed. The Dockerfile defaults remain `1000:1000` for
|
||||
self-hosted builds, and runtime identity overrides remain supported. The first
|
||||
build with the new identity must rebuild layers that depend on the base image;
|
||||
later builds can reuse those layers.
|
||||
|
||||
Verification and image building run concurrently, outside the full npm release's
|
||||
concurrency group. Different commits have independent groups. The npm canary
|
||||
release reuses `Cloud source verified v1` for the exact master push instead of
|
||||
starting a second copy of `Release Verify`. This source-only job depends on every
|
||||
source check but does not wait for Docker or migrator publication. npm canary
|
||||
publication remains possible when source verification passes and an image build
|
||||
fails. Stable releases and candidate-branch betas still run full verification.
|
||||
|
||||
The canary consumer requires the expected workflow ID and path, upstream source
|
||||
repository, master push event, full SHA, and a successful job in the latest run
|
||||
attempt. It checks the run again after reading the jobs to reject a concurrent
|
||||
rerun. Missing proof waits for up to 45 minutes; failed, skipped, cancelled,
|
||||
ambiguous, or mismatched proof cannot authorize publication. API failures fail
|
||||
closed. If a source check fails, fix it and rerun Cloud readiness before retrying
|
||||
the release. Use **Re-run all jobs** when a later attempt did not rerun the source
|
||||
proof; an earlier attempt's successful job is not accepted. This avoids duplicate
|
||||
test jobs on standard runners. Measure queue time to assess the timing gain.
|
||||
|
||||
Release verification spreads the general server suites across ten standard hosted
|
||||
runners, with the long chat suite split separately across three jobs. Each server
|
||||
job still runs one test worker. The partition covers every suite exactly once;
|
||||
normal PR and local test groups keep their existing shape. More jobs increase
|
||||
concurrent runner demand, so compare queue time as well as test duration.
|
||||
|
||||
All release verification installs, including the Runner scorer and chaos evals,
|
||||
allow pnpm to refresh an outdated lockfile. Contributor PRs leave lockfile updates
|
||||
to the separate refresh bot, so a dependency-changing master commit can arrive
|
||||
before that bot's PR merges. Verification must install and test that commit
|
||||
without waiting for another merge. The generated lockfile stays in the job's
|
||||
workspace; these checks do not commit it back to the repository.
|
||||
|
||||
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, stable verification, and standalone evals 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.
|
||||
|
||||
## Reserved AWS verification capacity
|
||||
|
||||
`AWS_POST_MERGE_CI_ENABLED=true` routes cloud source verification, artifact
|
||||
waiting, readiness signals, and exact-master migrator preparation to the
|
||||
`paperclip-post-merge` runner group. The separate Fleet label is
|
||||
`runs-on/fleet=paperclip-post-merge-x64/env=public-ci`. Its 36 reserved slots use
|
||||
the same four-vCPU, 16-GiB machines as approved PR jobs. PR capacity is reduced
|
||||
to 64; image capacity stays at eight. The total ceiling remains 108 runners.
|
||||
This keeps PR bursts from consuming every post-merge verification slot.
|
||||
|
||||
Every selector checks the canonical repository name and ID, master ref, and a
|
||||
push or manual event. Reusable verification also requires `inputs.ref` to equal
|
||||
that event's `github.sha`. The migrator route requires `cloud-migrator` and
|
||||
`inputs.source_ref == github.sha`. Branch/tag refs, PR events, arbitrary preview
|
||||
sources, and missing or disabled switches use GitHub-hosted runners. If another
|
||||
merge lands before a migrator dispatch resolves master, the older source uses
|
||||
GitHub-hosted runners too. npm publication always remains GitHub-hosted to keep
|
||||
its trusted-publisher identity.
|
||||
|
||||
Before enabling the switch, deploy the separate Fleet and restrict its GitHub
|
||||
runner group to repository ID `1170821064` and these workflows at
|
||||
`refs/heads/master`: `cloud-readiness.yml`, `cloud-artifacts.yml`,
|
||||
`release-verify.yml`, `runner-chaos-evals.yml`, and `release.yml`. Do not authorize
|
||||
PR-controlled workflow versions. PR placement retains its independent pinned
|
||||
workflow and six-account author/actor allowlist.
|
||||
|
||||
Disable the switch and rerun the whole workflow to restore GitHub-hosted
|
||||
placement. Assigned jobs keep their original runners. Readiness requirements,
|
||||
source checks, and npm integrity checks are unchanged.
|
||||
|
||||
## AWS cloud build routing
|
||||
|
||||
`AWS_CLOUD_BUILDS_ENABLED=true` routes the Docker cloud job to the
|
||||
`paperclip-cloud-build-x64` RunsOn Fleet for canonical `paperclipai/paperclip`
|
||||
master pushes and manual master runs. Forks, pull requests, and release tags
|
||||
retain GitHub-hosted runners. The separate `AWS_CI_ENABLED` and
|
||||
`AWS_CI_TRUSTED_USER_IDS` variables control PR routing.
|
||||
|
||||
The cloud Fleet uses a separate runner group, `paperclip-cloud-build`, restricted
|
||||
to this repository and `.github/workflows/docker-cloud.yml@refs/heads/master`.
|
||||
Provision that group and Fleet before enabling the variable. The cloud runners
|
||||
need at least 64 GiB free for Docker and the workspace; the initial configuration
|
||||
uses 120 GiB disks with the existing 4-vCPU, 16-GiB machine size. AWS jobs have
|
||||
a 40-minute workflow timeout so they finish before the 45-minute instance
|
||||
lifetime; GitHub-hosted jobs retain their 60-minute timeout. Keep the registry
|
||||
cache and all pushed-image verification steps enabled.
|
||||
|
||||
To roll back routing, set `AWS_CLOUD_BUILDS_ENABLED=false`, then rerun the cloud
|
||||
workflow. Changing the variable does not migrate an already assigned job.
|
||||
Check the Actions job's runner name and runner group to verify placement. Record
|
||||
queue time, image verification completion, and `Cloud deployable v1` separately;
|
||||
source verification and the migrator still run on GitHub-hosted runners.
|
||||
|
||||
|
||||
### Typecheck Rust dependency cache
|
||||
|
||||
Source verification's typecheck job builds the native Runner binary through the
|
||||
server's `prepare:runner-vendor` command. It restores and saves compiled Rust
|
||||
dependencies only for canonical master pushes that verify the event's exact SHA.
|
||||
The `release-typecheck-v1` cache is separate from Runner verification because
|
||||
those jobs compile different profiles. The pinned toolchain is selected before
|
||||
cache lookup. Workspace crates and installed cargo binaries are excluded, and
|
||||
all typechecks still execute. A missing or invalidated cache triggers compilation.
|
||||
|
||||
### pnpm dependency store cache
|
||||
|
||||
The Refresh Lockfile workflow does not cache the pnpm store. Its resolution-only
|
||||
command does not download packages and can save an empty default-branch cache
|
||||
before full install jobs finish. The PR policy job also leaves store caching off.
|
||||
|
||||
PR install jobs restore the pnpm store without saving it. They hash the checked-in
|
||||
lockfile before downloading the policy job's regenerated lockfile, matching the
|
||||
key format used by master install jobs. A same-OS, same-architecture pnpm fallback
|
||||
can reuse older package downloads when the exact key is absent. Each job still
|
||||
installs with `--frozen-lockfile` against the policy artifact when one exists;
|
||||
cache contents do not select dependency versions. A cache miss downloads packages
|
||||
normally. New PR-only dependencies may be downloaded again on each PR run until
|
||||
master populates a cache that contains them.
|
||||
|
||||
This avoids storing a full dependency archive under every PR merge ref. Those
|
||||
copies competed with the Rust caches for the repository's storage limit. Keep
|
||||
master cache writes enabled so trusted post-merge installs refresh shared stores.
|
||||
After activating the new trusted workflow pin, verify cache restores and package
|
||||
reuse in an allowlisted PR, and verify that no new `node-cache-` entries appear
|
||||
under its `refs/pull/<number>/merge` ref. Existing copies can expire normally.
|
||||
|
||||
The repository cache storage ceiling is managed in GitHub Settings, separately
|
||||
from this workflow. Check it with:
|
||||
|
||||
```sh
|
||||
gh api repos/paperclipai/paperclip/actions/cache/storage-limit
|
||||
```
|
||||
|
||||
Increasing the repository limit above 10 GB can require an organization owner to
|
||||
raise the maximum in organization Settings → Actions → General first. Repository
|
||||
administration access alone cannot override that maximum. Paid cache storage also
|
||||
requires a payment method and sufficient Actions Cache Storage budget; see the
|
||||
[GitHub cache storage documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#increasing-cache-size).
|
||||
Preserve populated master pnpm and Rust caches when inspecting pressure.
|
||||
|
||||
After deploying this correction, remove any existing empty default-branch entry
|
||||
for the current lockfile key. List cache IDs, branches, and archive sizes first:
|
||||
|
||||
```sh
|
||||
gh api --paginate 'repos/paperclipai/paperclip/actions/caches?ref=refs/heads/master&key=node-cache-Linux-x64-pnpm-&per_page=100' \
|
||||
--jq '.actions_caches[] | {id, ref, key, size_in_bytes}'
|
||||
```
|
||||
|
||||
Match the key and upload size against the cache-creation job's logs. The
|
||||
September 11 incident was cache ID `7559920987`, a 216-byte archive. This guarded
|
||||
command deletes only that observed entry. It leaves a populated replacement or
|
||||
an entry on another branch untouched, and does nothing if the old ID is absent:
|
||||
|
||||
```sh
|
||||
bad_cache_id=7559920987
|
||||
bad_cache_key=node-cache-Linux-x64-pnpm-c3096ecb02a34aaa9782baaadafcb731510e1dba10dd661618c3a2ee91e58fa5
|
||||
entries="$(gh api --paginate --slurp 'repos/paperclipai/paperclip/actions/caches?ref=refs/heads/master&per_page=100')"
|
||||
if printf '%s\n' "$entries" | jq -e --argjson id "$bad_cache_id" --arg key "$bad_cache_key" '
|
||||
[.[].actions_caches[] | select(.id == $id)] |
|
||||
length == 1 and .[0].ref == "refs/heads/master" and
|
||||
.[0].key == $key and .[0].size_in_bytes == 216
|
||||
' >/dev/null; then
|
||||
gh api --method DELETE "repos/paperclipai/paperclip/actions/caches/$bad_cache_id"
|
||||
fi
|
||||
```
|
||||
|
||||
A subsequent master install can populate the missing entry. Check the saved
|
||||
archive size and package reuse in install logs; a cache hit alone does not prove
|
||||
that the entry contains dependencies.
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Cloud UI snippet
|
||||
|
||||
Cloud operators can set `PAPERCLIP_CLOUD_UI_SNIPPET` to an HTML snippet.
|
||||
The server inserts it before `</body>` in static and Vite-served UI pages.
|
||||
It requires the existing Cloud-managed instance signal. Self-hosted instances
|
||||
ignore this setting. No snippet is enabled by default.
|
||||
|
||||
This is trusted deployment configuration, not user input. It executes in the
|
||||
application origin and is visible to every browser that receives the UI shell.
|
||||
Do not include secrets or customer data. Restart the app after changing it.
|
||||
Operators must review scripts and any required CSP changes before deployment.
|
||||
|
||||
## Base64 variant
|
||||
|
||||
Delivery pipelines that write env vars through provider APIs can sit behind
|
||||
web application firewalls that reject values containing raw script markup.
|
||||
`PAPERCLIP_CLOUD_UI_SNIPPET_B64` carries the same snippet through them as
|
||||
standard base64 of the UTF-8 HTML:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_CLOUD_UI_SNIPPET_B64="$(base64 < snippet.html)"
|
||||
```
|
||||
|
||||
Whitespace and line wrapping in the value are tolerated. A value that is not
|
||||
canonical padded base64 of UTF-8 text, or that decodes to blank, is ignored —
|
||||
if the widget does not appear, check that the value round-trips through
|
||||
`base64 -d`. A present `PAPERCLIP_CLOUD_UI_SNIPPET` always wins, blank
|
||||
included: clearing the plain variable to blank disables injection even while
|
||||
a base64 value is still deployed. Everything else about the snippet is
|
||||
unchanged.
|
||||
|
||||
## Plain closed beta
|
||||
|
||||
Set the value to this standard embed, replacing `YOUR_CHAT_APP_ID` with the
|
||||
public chat app ID for the target environment:
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function(d) {
|
||||
var script = d.createElement('script');
|
||||
script.src = 'https://chat.cdn-plain.com/index.js';
|
||||
script.onload = function() { Plain.init({ appId: 'YOUR_CHAT_APP_ID' }); };
|
||||
d.head.appendChild(script);
|
||||
})(document);
|
||||
</script>
|
||||
```
|
||||
|
||||
No signing secret or Plain API key is required. No Paperclip customer identity
|
||||
or organization data is passed. Plain manages the anonymous browser session;
|
||||
there is no Paperclip account-switch integration. Ask users for identifying
|
||||
information when needed. The existing feedback flag remains unchanged.
|
||||
|
||||
Docs: [Plain chat](https://www.plain.com/docs/product/channels/chat).
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
On staging, open `/`, `/index.html`, and an organization dashboard directly.
|
||||
Confirm the bubble appears and a test message reaches Plain. Verify the support
|
||||
reply returns. On a self-hosted instance, confirm no snippet or widget is loaded.
|
||||
Unset the snippet and restart to remove it on the next page load. Existing open
|
||||
tabs retain the widget until refreshed. No production deployment is implied.
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
# Composer Stop and task controls
|
||||
|
||||
The empty composer shows **Stop** while this task has a live execution and the
|
||||
viewer can manage task controls. Stop creates the same manual pause hold as
|
||||
**Pause work** / **Pause subtree** in the task menu. A parent pause includes its
|
||||
descendants. Cancellation remains a separate menu action.
|
||||
|
||||
Text (after trimming) or attachments switch the button back to Send. Uploading
|
||||
and failed attachments retain their existing restrictions. Keyboard submission
|
||||
never invokes Stop. Queued-message editing and structured interactions keep
|
||||
their existing actions, and text entered while stopping remains in the draft.
|
||||
|
||||
Pause dispatches without a preview dialog or reason. The button stays pending
|
||||
while affected run state is checked; native cancellation must be acknowledged.
|
||||
A saved hold with unconfirmed termination produces an error rather than a
|
||||
success claim. The cancellation dialog requires a valid preview and excludes
|
||||
terminal tasks. Resume/restore retain the optional wake-agents checkbox.
|
||||
|
||||
## Resume and execution recovery
|
||||
|
||||
Resume releases a hold. Waking agents is optional and only applies to tasks in
|
||||
`todo`, `in_progress`, or `in_review`; parked and terminal tasks stay untouched.
|
||||
The current execution-recovery policy requires verified outcomes before a stopped
|
||||
provider can restart. If any affected task still needs that reconciliation,
|
||||
Resume with wake enabled returns an inline error and preserves the pause. The
|
||||
operator can release the pause without waking agents, then use the existing
|
||||
execution-reconciliation flow after reviewing the stopped run. Resume does not
|
||||
claim that unknown provider actions completed or were never performed.
|
||||
|
||||
A completed release remains successful if a best-effort wake fails. Its response
|
||||
includes optional `wakeFailures`, the page reports them inline, and remaining
|
||||
eligible tasks still receive their wake requests. No new endpoint is introduced.
|
||||
The deterministic E2E fixtures prove interruption, then record their known lack
|
||||
of external effects through the existing reconciliation API before continuing.
|
||||
|
||||
Embedded ACP also supports verified continuation of an interrupted local session.
|
||||
The adapter must acknowledge cancellation and prove a preserved session with
|
||||
settled read-only work. Stop waits for provider cleanup. Forced local termination uses the actual
|
||||
child-process handle captured at spawn, including on Windows. An unavailable
|
||||
handle does not authorize a signal or replay. Unknown actions remain
|
||||
blocked, and task detail shows the reason even after recovery bookkeeping resolves.
|
||||
A run-level Stop leaves the task unpaused; a subsequent comment can continue the
|
||||
same session with the earlier queued messages. Composer Stop still creates a
|
||||
pause hold. New board messages require Resume first. Both comment creation and
|
||||
updates that include a comment return `409` while an effective task or ancestor
|
||||
pause hold is active. Interrupted agents may still report their results. Neither path permits a fresh-session fallback
|
||||
when the interrupted checkpoint cannot be restored.
|
||||
|
||||
The credential-free ACP regression journey uses an actual ACP child process:
|
||||
|
||||
```sh
|
||||
pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/acp-stop-continuation.spec.ts
|
||||
```
|
||||
|
||||
It covers the queued-follow-up sequence (queue a second request, stop, then send “go”),
|
||||
same-session delivery of both messages, and an unfinished write that stops
|
||||
mutating its file but retains a visible execution blocker. Unit and integration
|
||||
tests additionally cover pre-start Stop, unavailable/changed sessions, rotating
|
||||
scratch directories, cancellation acknowledgment, a provider that hangs during
|
||||
cleanup after returning cancellation, deferred-wake adoption, and
|
||||
company-scoped blocker lookup. Hosted-provider behavior is a separate smoke test.
|
||||
|
||||
The browser tests also require the continued provider to complete the task through
|
||||
the agent API. Restoring a session must refresh its run identity, API credential,
|
||||
and scratch environment. The same conversation must not reuse the stopped run's
|
||||
credential. A regression test checks distinct run IDs and token hashes across the
|
||||
restart without logging the credentials themselves.
|
||||
|
||||
Historical behavior, superseded by the composer takeover: on 2026-09-09, all three ACP browser journeys passed. A manual browser walk-through
|
||||
also queued a request, used composer Stop, sent “go” while paused, and selected
|
||||
Resume work. The pause stayed in place during the conversation reply. Resume
|
||||
restored the same provider session, answered the pending request once, and moved
|
||||
the task to Done through the current run's authenticated API call. These tests use
|
||||
a deterministic ACP child process; they do not call Drive or another external app.
|
||||
The manual unfinished-write check also confirmed that file size stayed unchanged
|
||||
for five seconds after Interrupt. Sending “go” displayed the reconciliation reason
|
||||
and did not start another provider prompt.
|
||||
|
||||
A separate live Claude ACP smoke test interrupted a Bash tool writing only to a
|
||||
disposable local file: cancellation settled in 1,167 ms, output remained unchanged
|
||||
for five seconds, and no matching tool process remained. The shell action correctly
|
||||
did not receive automatic replay permission. A second live Claude check interrupted
|
||||
a response with no tools, restored the exact same provider session, and received
|
||||
the requested follow-up answer. These are local-provider observations, not a
|
||||
latency guarantee or proof for every provider and remote sandbox.
|
||||
|
||||
## Quiet task feedback
|
||||
|
||||
The visible task/subtree does not produce duplicate state toasts. Its live
|
||||
notifications are suppressed while foregrounded, including descendant runs;
|
||||
unrelated and background work retains notifications. Tree-control results use
|
||||
inline state, and failures stay in the composer, page, or confirmation dialog.
|
||||
The amber composer takeover contains “Subtree is paused.” (or “Task is paused.”),
|
||||
a short instruction to resume before sending, and Resume. It replaces input
|
||||
controls in both task interfaces, cannot be dismissed, and preserves text and
|
||||
attachment drafts. An inherited hold links to the ancestor task. Pending resume
|
||||
keeps the takeover visible; failed resume leaves the task paused. Expected cancellation uses a muted gray disclosure with optional details.
|
||||
This is recorded as a product rule in `DESIGN.md`.
|
||||
|
||||
The follow-up passed 213 focused tests, both isolated runner E2E journeys
|
||||
(including no-toast assertions), UI typecheck/build, token gates, and Storybook
|
||||
build. Paused, expanded cancellation, Stop without toasts, mobile, and light
|
||||
stories were inspected in the browser.
|
||||
|
||||
## Storybook
|
||||
|
||||
Run from the worktree:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/ui exec storybook dev -p 6016 -c storybook/.storybook --no-open
|
||||
```
|
||||
|
||||
Open `http://localhost:6016/?path=/story/tasks-execution-controls--running-empty`.
|
||||
The `Tasks / Execution Controls` stories compose the production composer and
|
||||
menu/dialog controls together. They cover text switching, attachment-only,
|
||||
idle, stopping, paused, errors, cancellation preview/loading, and mobile/light
|
||||
presentations. The Storybook state transitions simulate requests; runner
|
||||
verification belongs to the isolated browser suite below.
|
||||
|
||||
## Automated verification
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --project @paperclipai/ui ui/src/components/task-chat/TaskChatComposer.test.tsx ui/src/components/TaskChatThread.test.tsx ui/src/pages/IssueDetail.test.tsx ui/src/lib/wait-for-stopped-runs.test.ts
|
||||
pnpm exec vitest run --project @paperclipai/server server/src/__tests__/issue-tree-control-routes.test.ts
|
||||
pnpm check:token-gates
|
||||
pnpm build-storybook
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
The component/page tests cover whitespace, attachments and upload restrictions,
|
||||
permissions, keyboard submission, queue edits, duplicate clicks, draft
|
||||
preservation, shared pause requests, compact cancellation, and visible errors.
|
||||
Stop verification tests include continued execution, native acknowledgment,
|
||||
network failures, and hung status requests. Route tests cover pause dispatch,
|
||||
authorization, cancellation, and opt-in resume wakeups with terminal/company
|
||||
exclusions.
|
||||
|
||||
## Isolated browser acceptance
|
||||
|
||||
Legacy process coverage needs no provider credentials:
|
||||
|
||||
```sh
|
||||
pnpm exec playwright test --config tests/e2e/playwright-composer-stop.config.ts
|
||||
```
|
||||
|
||||
To include native execution, build the real runner and deterministic Codex
|
||||
protocol fixture from this checkout, then point the suite at their absolute
|
||||
paths:
|
||||
|
||||
```sh
|
||||
cargo build --manifest-path packages/paperclip-runner/runner/Cargo.toml --bin paperclip-runnerd --bin fake-codex-app-server
|
||||
PAPERCLIP_STOP_FAKE_CODEX="$PWD/packages/paperclip-runner/runner/target/debug/fake-codex-app-server" \
|
||||
PAPERCLIP_RUNNER_BINARY="$PWD/packages/paperclip-runner/runner/target/debug/paperclip-runnerd" \
|
||||
pnpm exec playwright test --config tests/e2e/playwright-composer-stop.config.ts
|
||||
```
|
||||
|
||||
The suite boots a disposable local-trusted instance on port 3199 (override with
|
||||
`PAPERCLIP_E2E_PORT`). It never attaches to an existing server. Native coverage
|
||||
is explicitly skipped without the fixture; it must not use a logged-in provider
|
||||
as a fallback. Test companies are archived during cleanup.
|
||||
|
||||
For each runner, the journey starts a parent, child, and unrelated task, plus a
|
||||
terminal child. It sends while running and verifies the durable queue, clicks
|
||||
Stop, verifies interruption and the persisted hold, and observes three
|
||||
ten-second scheduler intervals without continuation. It resumes with wakeups,
|
||||
pauses from the menu, dismisses and then confirms cancellation, and verifies
|
||||
terminal-task exclusions and unrelated execution.
|
||||
|
||||
Timing attachments distinguish click-to-request from request-to-observed-stop.
|
||||
Native proof requires provider `turn/start` before the click, `turn/interrupt`
|
||||
after it, and durable cancellation acknowledgment. Legacy proof checks the
|
||||
actual parent and child PIDs have exited with a one-second configured grace
|
||||
period. HTTP success alone is insufficient.
|
||||
|
||||
For a release with real providers, repeat while an actual long-running tool is
|
||||
active, confirm the tool's own process/output stops, and verify a queued message
|
||||
is consumed on continuation. The deterministic provider exercises the native
|
||||
transport and cancellation protocol, not external provider behavior or every
|
||||
tool's process cleanup. Preserve these limits in any test report.
|
||||
|
||||
## Recorded acceptance run (2026-09-09)
|
||||
|
||||
Both isolated browser journeys passed. Legacy Stop dispatched after 214 ms and
|
||||
observed both runs stopped 155 ms after dispatch; native dispatched after 212 ms
|
||||
and observed stop 320 ms later. These are single local observations including
|
||||
browser automation overhead, not latency guarantees. Native used the real
|
||||
runnerd and the repository's deterministic Codex protocol fixture. Real hosted
|
||||
provider/tool cleanup remains a release acceptance check.
|
||||
|
||||
Repository typecheck, production build, Storybook build, token gates, targeted
|
||||
UI tests, and tree-control route tests passed. The broad UI run passed 5,491
|
||||
tests with one five-second timeout in an unchanged `IssuesList` test; rerunning
|
||||
that file passed all 46 tests. The repository test run encountered 17 failures
|
||||
in unchanged suites, all reproduced in the original checkout:
|
||||
|
||||
- `server/src/__tests__/workspace-runtime.test.ts`: 2 failures.
|
||||
- `server/src/services/workspace-runtime-exposure.test.ts`: 7 failures.
|
||||
- `server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts`: 4 failures.
|
||||
- `server/src/__tests__/workspace-instance-cleanup.test.ts`: 1 failure.
|
||||
- `server/src/__tests__/company-skills.test.ts`: 2 failures.
|
||||
- `server/src/__tests__/worktree-seed-server-spawn.test.ts`: 1 failure.
|
||||
|
||||
These baseline failures prevent a green repository-wide test result.
|
||||
The broad run was stopped after more than 30 minutes in its serial server lane
|
||||
once these failures were independently reproduced. Later full-suite groups
|
||||
did not run. The full UI suite and the feature's server route suite were run
|
||||
separately as described above.
|
||||
|
||||
The `Tasks / Composer / Paused task takeover` stories use the production composer
|
||||
and cover task/subtree holds, saved drafts, resume progress/failure, and light/mobile layouts.
|
||||
|
|
@ -11,14 +11,18 @@ Connection intents let an agent ask the responsible user for a known service con
|
|||
|
||||
Provider-specific setup must stay in the shared feature and `AppDefinition` metadata. Do not add provider forms or connection mutations to either host.
|
||||
|
||||
When a task connection needs Paperclip Cloud enrollment, the shared dialog opens enrollment in a separate window. The task keeps its access selection and interaction ID. A new-tab link is available if the window does not open. The dialog reads server enrollment status and refreshes the provider catalog after approval; enrollment alone does not mark the app connected. OAuth retains the interaction ID even if setup resumes in the page host, so the verified callback can resolve the task card and queue its continuation.
|
||||
|
||||
## Agent tools
|
||||
|
||||
Every active heartbeat with a responsible user receives two run-bound tools:
|
||||
|
||||
- `connections_search({ query })` searches first-party connectable definitions and returns `ready`, `needs_user_action`, `available`, or `unavailable` from the requesting agent's perspective.
|
||||
- `connection_request({ service })` returns immediately when the service is already usable. Otherwise it creates or reuses a `connection_intent` and instructs the agent to end the run pending continuation.
|
||||
- `connections_search({ query })` searches catalog names and descriptions, plus authorized configured MCP connections and indexed tool descriptions and returns `ready`, `needs_user_action`, `available`, or `unavailable` from the requesting agent's perspective.
|
||||
- `connection_request({ service })` returns immediately when the service is already usable. Otherwise it creates or reuses a `connection_intent` and instructs the agent to finish independent work, then yield pending continuation.
|
||||
|
||||
Claude and Codex receive the tools through a native managed MCP server. Local/process adapters receive `PAPERCLIP_RUNTIME_TOOLS_*` environment variables and CLI guidance. Cloud, HTTP, gateway, and external adapters receive the typed runtime descriptor in their invocation context; compatible adapters may also project it into their remote environment.
|
||||
The native Paperclip Runner advertises both tools through its server-owned tool authority even with an empty MCP assignment. It captures the current responsible identity at each call. Legacy Claude and Codex receive the tools through a managed MCP server. Local/process adapters receive `PAPERCLIP_RUNTIME_TOOLS_*` environment variables and CLI guidance. Cloud, HTTP, gateway, and external adapters receive the typed runtime descriptor in their invocation context; compatible adapters may also project it into their remote environment.
|
||||
|
||||
Legacy delivery uses the same intent service, setup card, and fresh-session resolution wake. Environment and descriptor delivery require the receiving harness to consume them; they do not establish support in every third-party runtime. The default legacy prompt includes the canonical discovery guidance. A custom `promptTemplate` replaces that default and should retain the connection guidance if proactive discovery is desired.
|
||||
|
||||
The equivalent CLI helpers are:
|
||||
|
||||
|
|
@ -31,14 +35,46 @@ The manually configured Paperclip MCP server also advertises `connections_search
|
|||
|
||||
## Security and lifecycle
|
||||
|
||||
- Company, agent, run, task, and responsible user come only from the signed runtime token and stored heartbeat context.
|
||||
- Company, agent, run, task, and responsible user come only from the signed legacy runtime token or the native server-owned binding and stored execution identity.
|
||||
- Tokens are scoped to connection intents, expire after one hour, and are rejected when the heartbeat is no longer running.
|
||||
- The thread payload contains only service identity, requesting-agent identity, and a safe phase. It never contains credentials or authorization URLs.
|
||||
- OAuth state is linked to the interaction. The same-origin callback finalizes the existing connection pipeline, posts only interaction ID/outcome to its opener, and redirects back to the task if there is no opener.
|
||||
- Personal OAuth defaults to the addressed user and creates an explicit delegation to the requesting agent. Reuse and installs are additive.
|
||||
- Task-hosted setup locks install reach to the requesting agent; the store host retains its normal broader access choices.
|
||||
- The intent resolves only after the connection, grant/delegation, profile access, and install succeed. Failures remain pending with `needs_retry`.
|
||||
- Closing the task, a newer run requesting the same service, or a newer human task comment expires the intent and deletes linked OAuth state.
|
||||
- Success and decline wake the assignee once using an interaction-and-status idempotency key and force a fresh continuation session.
|
||||
- Closing or reassigning the task expires pending intents and deletes linked OAuth state. Ordinary comments and later runs preserve the pending card. Requests reuse the same task, requester, addressed user and service; a different addressed user supersedes an older request for that service.
|
||||
- Success and explicit decline atomically persist a continuation delivery with resolution. A leased startup/periodic worker dispatches through heartbeat with a unique `connection-intent:<interaction>:<outcome>` wake key. It checks assignment, status, membership and current executable access, retries paused/suppressed delivery, and recovers a crash after enqueue without creating a second wake. The continuation forces a fresh provider session; heartbeat queues it behind active execution.
|
||||
|
||||
Legacy `request_confirmation.payload.connectionAuthorization` interactions remain readable and resolvable. New agent requests use `connection_intent` exclusively.
|
||||
|
||||
## Model evaluations
|
||||
|
||||
The companion `paperclip-evals` repository owns the connection cases in
|
||||
`evals/runner-api-tools/connection-cases.json`. They use the existing real-server
|
||||
API-tool eval controller and `scripts/runner-api-eval-worker.ts`, with this
|
||||
checkout's production tool definitions, connection guidance, and authority.
|
||||
Natural prompts measure discovery and request selection; explicit contract probes
|
||||
measure deduplication, readiness, and denied targets. The fixture helper supplies
|
||||
isolated company records and retains initial and final connection interaction
|
||||
state. Scoring requires observed calls/results and persisted state, not an
|
||||
assistant's claim of having connected.
|
||||
|
||||
These tool evals do not perform live provider OAuth or establish browser quality.
|
||||
The native and legacy connection browser suites separately cover setup and
|
||||
continuation, while the live-provider journey report records actual authorization
|
||||
and data-read coverage.
|
||||
|
||||
|
||||
## Custom targets, readiness and recovery
|
||||
|
||||
Catalog slugs remain stable. Search also returns `connection:<uuid>` for configured custom connections whose active identity grants authorize the responsible person, their company, or the requesting agent. Identifiers are never interpreted as URLs. Configured metadata and tool descriptions, including catalog-provider descriptions, are read only after the company and identity audience checks. Setup choices expose only display and selection metadata; they never include connection configuration, transport settings, or credential fields. Discovery reads the stored index without refreshing providers. Search is ranked with exact provider matches first and capped at 20 results.
|
||||
|
||||
`ready` requires an installed, enabled, healthy executable connection, permitted catalog tools, and a usable runtime identity. An installed connection with denied actions is administrative denial rather than a request to reauthenticate. Runtime calls continue enforcing access after a historical card resolves. If access is ready but the native provider's pinned tool snapshot is older, `connection_request` queues a fresh session without another authorization card.
|
||||
|
||||
Task setup defaults to personal identity when supported and the requesting agent's install reach. Existing installs are additive. An OAuth callback from a task prepares the catalog without adding access. Intent completion validates the current task and identity, then adds the requesting agent’s binding and install in the resolution transaction. Callback messages do not establish authorization: the card reloads the durable server result. A blocked popup offers a new-tab fallback; closing or declining provider sign-in keeps the request retryable. Only the card's **Not now** action declines the request. Decline continuations are told to pursue alternatives and cannot immediately request the same service again.
|
||||
|
||||
## Verification
|
||||
|
||||
Service and native-authority tests cover discovery, current identity, company boundaries, cross-run deduplication, additive grants and installs, permission denial, resolution atomicity, restart delivery and stale assignment. `tests/e2e/in-feed-native/playwright.config.ts` starts source `test-drive` instances with fresh data directories and a deterministic fake Codex provider plus MCP server. It exercises the real native runner and gateway; it is fixture proof, not live Notion or GitHub proof. Run with `pnpm exec playwright test -c tests/e2e/in-feed-native/playwright.config.ts`.
|
||||
|
||||
Offline Storybook examples live in `ui/storybook/stories/in-feed-connections.stories.tsx`. Build with `pnpm --filter @paperclipai/ui build-storybook`, then run `pnpm exec playwright test -c tests/storybook-visual/in-feed-connections.config.ts`. The suite checks every independently addressable story in both themes, catches play-function failures, and saves screenshots. Live provider acceptance additionally requires a model credential and a test workspace/account; do not describe fixture results or local-trusted testing as authenticated/cloud acceptance.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
# AgentMail Daytona verification — 2026-09-11
|
||||
|
||||
Worktree: `codex/agentmail`; isolated test drive at `http://localhost:3103`.
|
||||
The original checkout remains untouched. Test mail used only the two previously
|
||||
authorized inboxes, `pap15838-qa@agentmail.to` and
|
||||
`attractiveforce961@agentmail.to`.
|
||||
|
||||
## Defects corrected
|
||||
|
||||
- AgentMail REST connections fell through the generic health-check branch into
|
||||
local-stdio MCP validation. Both saved account credentials and inbox credentials
|
||||
now validate against AgentMail's `/auth/me` API. Catalog refresh returns no MCP
|
||||
tools, and invalid keys still produce a failed health result. The live Apps card
|
||||
was inspected in the browser and showed Connected with no stdio error.
|
||||
- Sandbox callback routing omitted task-email endpoints. It now allows assigned
|
||||
inbox discovery, task-thread reads, delivery reads, and explicit sends. Server
|
||||
company, inbox, task/run, and action-policy authorization remains in force.
|
||||
Setup, credentials, reconnect, and operator delivery resolution stay denied.
|
||||
- Shell-backed sandbox reads did not preserve ENOENT for a missing optional
|
||||
Codex `auth.json`, causing cleanup to fail after a successful email send. Reads
|
||||
now confirm absence in a searchable parent and return ENOENT; actual read and
|
||||
transport failures still propagate. This lets existing auth copy-back treat
|
||||
missing credentials as a no-op.
|
||||
- Runtime instructions now document inbox discovery directly; the agent otherwise
|
||||
spent time guessing that endpoint when initiating a new conversation.
|
||||
|
||||
## Live observations
|
||||
|
||||
The board used the ordinary task composer in
|
||||
[AGE-10](http://localhost:3103/AGE/issues/AGE-10) to request a test email.
|
||||
The agent executed the real Codex CLI in Daytona, used the sandbox callback
|
||||
bridge to discover its assigned inbox and queue the send, and created
|
||||
[AGE-11](http://localhost:3103/AGE/issues/AGE-11) as an email child task.
|
||||
|
||||
- Provider sandbox: `c2f176ca-dbde-41a6-995d-aefa4689e4c5`.
|
||||
- Runtime verified by the agent: Linux, x86_64; hostname matched the sandbox.
|
||||
- Run: `cd7b934a-5555-4361-b0e5-b8106c1510ce`.
|
||||
- Publication: `d5b7bf41-a583-4f9f-90c0-4d21680e39c2`, **Delivered**.
|
||||
- Subject: `[Paperclip E2E] Daytona sandbox — Sep 11`.
|
||||
- Provider key remained in Paperclip's vault. The sandbox used its injected
|
||||
Paperclip run credential, and the model key was separately vaulted.
|
||||
|
||||
The first fixture launches exposed an unavailable default ACP executable and a
|
||||
host `service_tier` setting incompatible with the fleet image's Codex CLI. The
|
||||
QA fixture explicitly selects the CLI engine and an isolated Codex home. Earlier
|
||||
failed launches remain in AGE-9. The outbound send above completed, but its run
|
||||
then failed during missing-auth-file cleanup; the cleanup fix is verified
|
||||
separately below rather than rewriting that history.
|
||||
|
||||
## Cleanup verification
|
||||
|
||||
A fresh Daytona run in [AGE-12](http://localhost:3103/AGE/issues/AGE-12)
|
||||
read the existing publication, confirmed Delivered, recorded its Linux hostname,
|
||||
and completed successfully without sending another email.
|
||||
|
||||
- Run: `39e77902-714e-455f-90d8-8709f2d13762`, **Succeeded**.
|
||||
- Sandbox: `d50c6979-de6e-4a0c-ac18-bd616a39ee1f`.
|
||||
- Cleanup log: “no sandbox credential to copy back (absent auth.json); host
|
||||
credential kept.” The environment lease reached Released.
|
||||
|
||||
## Automated checks
|
||||
|
||||
- 20 durable email pipeline tests passed, including health checks for account and
|
||||
inbox credentials, catalog discovery, and invalid credentials.
|
||||
- 56 sandbox callback bridge tests passed, including the four email routes and
|
||||
denial of email administration routes.
|
||||
- 28 command-managed runtime tests passed, including the missing-file contract
|
||||
and propagation of real read failures.
|
||||
- 4 capability inventory tests passed. Regenerated both capability indexes for
|
||||
the new task-email runtime documentation and updated the expected row count.
|
||||
- Server typecheck, server build, adapter-utils build, and whitespace checks passed.
|
||||
|
||||
## Inbound round trip
|
||||
|
||||
After Chrome access recovered, sent a new authorized test email from the other
|
||||
inbox through AgentMail Console. WebSocket intake created
|
||||
[AGE-13](http://localhost:3103/AGE/issues/AGE-13), assigned Email QA, and started
|
||||
the agent in a fresh Daytona sandbox. The agent read the bound thread, explicitly
|
||||
replied once, checked delivery, and marked the task Done.
|
||||
|
||||
- Run: `76be255b-df2e-4479-8c62-f4506f039132`, **Succeeded**.
|
||||
- Sandbox/verified Linux hostname: `7bb660fa-3cff-4b26-9e10-68c884be21bb`.
|
||||
- Reply publication: `efdd704c-afd3-4025-ab48-24fab6c97333`, **Delivered**.
|
||||
- Incoming comment persisted at `19:05:14.750Z`; run started at
|
||||
`19:05:14.920Z` (170 ms later). This interval excludes provider delivery and
|
||||
does not measure model startup. The run finished at `19:06:09.481Z`.
|
||||
- Exactly one incoming and one outgoing email comment, plus an internal summary.
|
||||
- Visually verified the exact acknowledgement in
|
||||
[the other AgentMail inbox](https://console.agentmail.to/dashboard/inboxes/attractiveforce961@agentmail.to?thread=805fd7f1-26c2-414a-b139-5fb65f490f50),
|
||||
with matching reply message ID and original-message reference.
|
||||
|
||||
## Test cleanup
|
||||
|
||||
Restored Email QA's original local adapter configuration. Removed the temporary
|
||||
Daytona environments, all six sandbox instances created by this test, and the
|
||||
temporary vaulted Daytona/model credentials. Provider inboxes, saved AgentMail
|
||||
credentials, task history, and run evidence remain available.
|
||||
|
||||
## Qualification limits
|
||||
|
||||
This run exercises the Codex CLI sandbox adapter. The native runner `task_email`
|
||||
path is covered deterministically, but was not separately live-qualified in Daytona.
|
||||
The Daytona inbound round trip used WebSocket intake. Earlier local-agent
|
||||
WebSocket and signed-webhook qualification is documented in
|
||||
[the main verification report](AGENTMAIL-VERIFICATION.md).
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
# AgentMail verification — 2026-09-11
|
||||
|
||||
## Environment
|
||||
|
||||
Worktree: `codex/agentmail`. The original checkout and its merge conflicts were
|
||||
preserved. Live checks used the isolated AgentMail Test Drive company at
|
||||
`http://localhost:3103`, with the experimental connections feature enabled.
|
||||
|
||||
Only these user-authorized inboxes exchanged test mail:
|
||||
|
||||
- Paperclip: `pap15838-qa@agentmail.to`, assigned to Email QA.
|
||||
- Other end: `attractiveforce961@agentmail.to`, inspected in AgentMail Console.
|
||||
|
||||
## Live browser results
|
||||
|
||||
| Journey | Observed result |
|
||||
| --- | --- |
|
||||
| Connect from the Apps catalog | Saved personal human access, selected agent access, and a vaulted key through the real UI. |
|
||||
| Give an agent an address | Used Permissions → three-step wizard → existing scoped inbox. The selected agent, review warnings, and connection persisted. |
|
||||
| Trust controls | Saved Low-trust review with a root-task boundary, verified the missing-sandbox prerequisite, then explicitly restored Standard for this local QA agent. |
|
||||
| Live receiving | Inbound correspondence created AGE-6. The agent explicitly replied once; the reply appeared in AgentMail Console and Paperclip recorded Delivered. |
|
||||
| Signed webhook | Registered an inbox-scoped webhook. Actual signed POSTs returned 204. AGE-7 received its email, the agent replied once, and both consoles showed the exchange. |
|
||||
| Reply to a completed conversation | New mail reused the same task and reopened it. |
|
||||
| Restart catch-up | Sent another reply while the server health endpoint was unreachable. Startup imported it into AGE-7, woke the agent, and sent one acknowledgement in the same thread. |
|
||||
| Agent-initiated new conversation | A board request in AGE-7 caused the agent to create AGE-8 with `parentId` pointing to AGE-7. One email was sent and marked Delivered; it appeared as a separate thread in AgentMail Console. |
|
||||
| Internal publication boundary | Internal summaries and the outbound-only child task's “No reply sent” response produced no additional emails. |
|
||||
| Cleanup | Restored WebSocket mode, removed Paperclip's test webhook, stopped the webhook-only proxy/tunnel, and removed the temporary public URL from the isolated configuration. Inbox history and vaulted test credentials remain inspectable. |
|
||||
|
||||
Useful live pages:
|
||||
|
||||
- [Saved connection permissions](http://localhost:3103/AGE/apps/78dd5c23-f60f-42ca-b30a-6f0c701b38d3/permissions)
|
||||
- [Inbox settings](http://localhost:3103/AGE/apps/chat/7cdf17d6-465e-4eef-8858-2b545be64b3a/settings)
|
||||
- [Inbound conversation and restart recovery: AGE-7](http://localhost:3103/AGE/issues/AGE-7)
|
||||
- [Agent-created email child: AGE-8](http://localhost:3103/AGE/issues/AGE-8)
|
||||
- [Other inbox in AgentMail Console](https://console.agentmail.to/dashboard/inboxes/attractiveforce961@agentmail.to)
|
||||
|
||||
## Timing
|
||||
|
||||
These are individual observations from `email.received` audit records, not a
|
||||
load test or latency guarantee. Admission-to-wakeup includes durable processing
|
||||
and heartbeat admission; it excludes provider delivery and subsequent model
|
||||
startup/generation.
|
||||
|
||||
| Check | Admission to wakeup |
|
||||
| --- | ---: |
|
||||
| Live inbound, AGE-6 | 409 ms |
|
||||
| Signed webhook, AGE-7 | 421 ms |
|
||||
| Startup catch-up, AGE-7 | 585 ms |
|
||||
|
||||
The clean webhook run was created at `18:10:53.471Z`, started at
|
||||
`18:10:53.512Z`, sent its reply at approximately `18:11:40Z`, and finished at
|
||||
`18:12:05.225Z`. Model work is separate from the sub-second admission measurement.
|
||||
|
||||
## Fixes found by testing
|
||||
|
||||
- Personal credential access displayed as organization access in the generic
|
||||
connection panel. AgentMail now displays the actual saved grants and installs.
|
||||
- Low-trust permissions used the wrong mutation route; Standard omitted rather
|
||||
than cleared the previous boundary. Both are fixed and covered by regressions.
|
||||
- Email task recovery incorrectly entered restricted chat replay. Normal email
|
||||
work now uses normal task recovery while retaining execution controls.
|
||||
- A send/read-only key could not register a webhook. Setup now explains the
|
||||
required inbox-scoped webhook permissions. A failed switch leaves the live
|
||||
connection active. The user authorized a replacement scoped key for the live
|
||||
webhook test.
|
||||
- Graceful shutdown retained the socket lease until its crash timeout. Shutdown
|
||||
now releases only this worker's socket tokens; the ownership test verifies
|
||||
immediate takeover by a second worker. The final live restart became ready at
|
||||
`18:30:10Z` and completed a mail check at `18:30:14Z`, with no connection error.
|
||||
- A path-like attachment filename could produce a stored object key that the
|
||||
storage reader rejected. Imported filenames now remove path traversal segments.
|
||||
The regression covers bounded, deduplicated intake, reading stored bytes,
|
||||
task-scoped attachment references, and rejecting bytes changed after queueing.
|
||||
- The initial QA agent attempted to install the released CLI for an unreleased
|
||||
feature. The test agent now uses the local HTTP API. Runtime documentation also
|
||||
describes the direct HTTP fallback.
|
||||
- The first QA instruction to leave work open omitted a valid task disposition,
|
||||
triggering existing recovery controls after a successful send. Corrected QA
|
||||
instructions explicitly set the requested disposition. Clean subsequent runs
|
||||
completed successfully; those earlier diagnostic tasks remain inspectable.
|
||||
|
||||
## Automated verification
|
||||
|
||||
- API/provider and durable-pipeline tests: 32 passed, including signature checks,
|
||||
deduplication, callback-before-response, uncertain-send handling, inbox/company
|
||||
isolation, credentials, low-trust placement, and socket ownership/shutdown.
|
||||
- OpenAPI contract checks passed (8 tests); the final combined run passed all 40.
|
||||
- Deterministic Playwright setup and task-conversation coverage includes actual
|
||||
trust-permission persistence, rich email cards, and Bcc details. Following the
|
||||
board UX revision, email controls were removed and instructions use the normal
|
||||
task composer. Its provider responses are mocked; it is separate from the live
|
||||
browser results above.
|
||||
- Trust UI tests passed (10 tests).
|
||||
- Catalog regression and damaged-runner-history recovery regression passed.
|
||||
- Repository typecheck and build passed; changed-package checks were repeated
|
||||
after subsequent fixes. Token gates and whitespace checks passed.
|
||||
- Full repository Vitest run did **not** pass. The general server group finished
|
||||
with 10,584 passing tests, five failing tests, and one database-startup suite
|
||||
failure. Its five individual failures subsequently passed in focused reruns
|
||||
(email recovery/trust, gallery count, plugin wait, and damaged runner history).
|
||||
This broad run began before the final fixes; it is not a final green result.
|
||||
- Additional broad workspace and serialized-route groups encountered database
|
||||
startup, hook, and adapter timeouts. The UI group had 5,923 passing tests and
|
||||
five failures; rerunning its two affected files passed all 73 tests. Shared
|
||||
contracts passed 727 tests and the skills catalog passed 20. Remaining broad
|
||||
groups have not been rerun to completion, so this is not a PR-ready all-green
|
||||
qualification.
|
||||
|
||||
## Limits
|
||||
|
||||
The account was at its inbox limit, so live setup attached an existing inbox.
|
||||
Programmatic inbox creation and custom domains were not live-qualified.
|
||||
Attachment transfer, invalid signatures, cross-company denial, cancellation,
|
||||
duplicate callbacks, and expired idempotency windows are checked deterministically
|
||||
rather than against the live provider. Low-trust execution was not run in a real sandbox; setup correctly
|
||||
rejected the isolated test drive's missing sandbox runtime.
|
||||
|
||||
One restart-test acknowledgement arrived in the other inbox while Paperclip's
|
||||
status remained Sent because its delivery receipt was missed during socket
|
||||
recovery. Sent records provider acceptance; Paperclip does not fabricate a
|
||||
Delivered receipt or resend the message. The later independent outbound email
|
||||
received and recorded its Delivered receipt normally.
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
# AgentMail email connections
|
||||
|
||||
AgentMail is an experimental **channel** connection. Enable experimental chat
|
||||
connections, open Apps → AgentMail, select which humans and agents may use the
|
||||
credential, then enter an API key. In the saved connection’s Permissions page,
|
||||
choose **Give an agent an email address**. The three-step wizard selects an agent,
|
||||
creates or attaches an address, and reviews the setup. Selecting an agent outside
|
||||
the current allowed list adds that agent when setup completes. Every provider thread in that inbox has one Paperclip task. Subjects are
|
||||
not identifiers. The same email delivered to two connected inboxes creates two
|
||||
independent tasks.
|
||||
|
||||
Setup accepts an AgentMail API key or the saved company credential from another
|
||||
AgentMail connection. Organization and pod keys create an inbox-scoped runtime
|
||||
key. An existing inbox-scoped key can connect only its own inbox. Credentials
|
||||
are vaulted and resolved by the server; they are not passed to agents. An inbox
|
||||
can have only one non-archived Paperclip endpoint across the instance.
|
||||
|
||||
Verified custom domains are selectable after checking the API key. Complete DNS
|
||||
setup in [AgentMail](https://docs.agentmail.to/custom-domains). Paperclip does not
|
||||
register domains or manage DNS.
|
||||
|
||||
The setup and Permissions page warn that an unrestricted inbox can receive mail
|
||||
from anyone. Configure sender allowlists in AgentMail; Paperclip does not manage
|
||||
or verify them. AgentMail controls new-message and reply lists separately. The
|
||||
wizard recommends Paperclip’s existing **Low-trust review** preset and lets the
|
||||
operator configure a project or root-task boundary. Incoming tasks are placed
|
||||
inside that boundary. Low-trust execution also requires isolated workspaces and an active sandbox
|
||||
environment selected for the agent; setup rejects an unavailable runtime. New
|
||||
inbound tasks request isolated execution. The trust preset itself does not
|
||||
sandbox filesystem or network access. Standard agents remain selectable with a warning.
|
||||
|
||||
Removing the assigned agent’s saved-connection access or revoking its credential
|
||||
grant stops receiving and sending. Connection creation saves the vaulted binding,
|
||||
human grants, and agent access in one database transaction.
|
||||
|
||||
## Receiving and task lifecycle
|
||||
|
||||
WebSocket is the default and needs no public HTTP URL. The server authenticates
|
||||
with an Authorization header, keeping the provider key out of the connection URL
|
||||
([provider handshake](https://www.agentmail.to/docs/api-reference/websockets/websockets)). The service holds a
|
||||
renewable database lease, subscribes to the connected inbox, and reconnects with
|
||||
backoff. Webhook mode needs the configured public HTTPS webhook base URL. Setup
|
||||
registers a Paperclip-owned webhook. The raw request body is verified using Svix
|
||||
before the inbox is admitted to the shared durable delivery queue.
|
||||
The API key needs inbox-scoped `webhook_create`, `webhook_read`, and
|
||||
`webhook_delete` permissions in addition to mail access. AgentMail's
|
||||
"Send & read mail" preset alone cannot register a webhook. A rejected
|
||||
registration while switching from WebSocket leaves live receiving active.
|
||||
|
||||
Both transports deduplicate by inbox, event kind, and provider message ID. A
|
||||
per-conversation worker lease serializes work; independent conversations can
|
||||
proceed concurrently. Provider messages, comments, and attachment links preserve
|
||||
the provider message identity. A reply to a completed task reopens it. A cancelled
|
||||
task retains new mail but does not wake its agent. Provider-classified spam,
|
||||
blocked and unauthenticated mail do not start automatic work. Recognized automatic
|
||||
replies can be retained in an existing conversation but do not wake an agent or
|
||||
create a new task.
|
||||
|
||||
Activation establishes the intake cutoff. Activation, reconnect, and periodic
|
||||
maintenance scan paginated message metadata and fetch eligible messages using a
|
||||
receipt-time checkpoint with a five-minute overlap. Metadata scans traverse all
|
||||
pages because AgentMail sorts messages by the sender's timestamp: a newly
|
||||
received message can have an old Date header. Message-ID deduplication makes
|
||||
repeated scans safe. Earlier messages in a newly active thread are imported as
|
||||
context without separate historical wakeups. There is no automatic historical
|
||||
mailbox import and no assumption of WebSocket replay.
|
||||
|
||||
Incoming mail wakes the selected agent through its normal task execution path,
|
||||
including its configured permissions and budget controls. The external sender
|
||||
is recorded in the email envelope; an email address never grants Paperclip
|
||||
membership or board authority.
|
||||
|
||||
## Explicit email actions
|
||||
|
||||
Internal comments, progress, final responses, approvals, and errors never send
|
||||
email. Email endpoints have an explicit publication mode; shared automatic chat
|
||||
publication paths exclude them. Sending email does not close a task.
|
||||
|
||||
The task displays the email envelope, extracted reply text, full text context,
|
||||
attachments, and delivery outcomes. Use the normal task conversation to ask the
|
||||
agent to send an email or reply. There is no separate email composer or mode
|
||||
switch. The agent uses an explicit email action; task messages themselves are
|
||||
not sent as email. Reply uses Reply-To when present, otherwise the sender;
|
||||
reply-all must be requested.
|
||||
Bcc is retained in the originating envelope but is not copied to reply inputs.
|
||||
Remote email images are not rendered. Attachments use Paperclip's content-type,
|
||||
size, company, and task bounds.
|
||||
|
||||
An agent must own the inbox, be assigned the source task, and supply the running
|
||||
source task's `X-Paperclip-Run-Id` at acceptance. Board actions require company
|
||||
write access. Configured action policies apply to both. Authority is checked
|
||||
again when the durable send executes. A new conversation creates its child task
|
||||
and immutable send intent in one transaction before contacting AgentMail.
|
||||
|
||||
All paths below are relative to `/api`:
|
||||
|
||||
| Operation | Path |
|
||||
| --- | --- |
|
||||
| Save credential and human/agent access | `POST /companies/:companyId/email/connections` |
|
||||
| Inspect a saved credential | `POST /companies/:companyId/email/connections/:connectionId/inspect` |
|
||||
| List authorized inboxes | `GET /companies/:companyId/email/inboxes` |
|
||||
| Inspect setup credentials (connection manager) | `POST /companies/:companyId/email/inspect` |
|
||||
| Create or attach an inbox (connection manager) | `POST /companies/:companyId/email/inboxes` |
|
||||
| Pause, resume, disconnect | `POST /email/inboxes/:endpointId/control` |
|
||||
| Replace credentials / receiving mode | `POST /email/inboxes/:endpointId/reconnect` |
|
||||
| Start an email child task or reply | `POST /companies/:companyId/email/send` |
|
||||
| Read the email context of a bound task | `GET /companies/:companyId/email/tasks/:issueId` |
|
||||
| Read delivery outcome | `GET /companies/:companyId/email/deliveries/:publicationId` |
|
||||
| Resolve an uncertain outcome (connection manager) | `POST /companies/:companyId/email/deliveries/:publicationId/resolve` |
|
||||
|
||||
A new send request:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpointId": "<inbox-endpoint-uuid>",
|
||||
"parentIssueId": "<current-task-uuid>",
|
||||
"to": ["recipient@example.com"],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Question about the proposal",
|
||||
"text": "Could you clarify the delivery date?",
|
||||
"attachmentIds": [],
|
||||
"idempotencyKey": "<new-request-uuid>"
|
||||
}
|
||||
```
|
||||
|
||||
A reply request uses `conversationId` and `replyToMessageId` from the bound task:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpointId": "<inbox-endpoint-uuid>",
|
||||
"conversationId": "<email-conversation-uuid>",
|
||||
"replyToMessageId": "<provider-message-id>",
|
||||
"replyAll": false,
|
||||
"text": "Thanks, that answers the question.",
|
||||
"attachmentIds": [],
|
||||
"idempotencyKey": "<new-request-uuid>"
|
||||
}
|
||||
```
|
||||
|
||||
Native runners with an active, authorized inbox receive `agentmail_inboxes`,
|
||||
`agentmail_read_thread`, `agentmail_send`, and `agentmail_delivery`. The system
|
||||
also installs the AgentMail skill for those agents through the normal runtime
|
||||
skill path. These tools supply run authority and work independently of the
|
||||
optional generic runtime API rollout. Where enabled, `search_api` and `call_api`
|
||||
also expose these operations. The CLI uses the same authenticated
|
||||
operations and inherits the agent run ID:
|
||||
|
||||
```sh
|
||||
paperclipai email inboxes
|
||||
paperclipai email thread "$PAPERCLIP_TASK_ID"
|
||||
paperclipai email send --file email-request.json
|
||||
paperclipai email reply --file email-reply.json
|
||||
paperclipai email delivery '<publication-uuid>'
|
||||
```
|
||||
|
||||
A `202` response includes task, conversation, and publication IDs immediately.
|
||||
The publication progresses through queued, sent, delivered, failed, or uncertain.
|
||||
Delivery callbacks update that publication and do not create new correspondence.
|
||||
Retries reuse the same immutable request and provider idempotency key. The worker
|
||||
stops automatic retries after 23 hours, conservatively inside AgentMail's 24-hour
|
||||
deduplication window. An uncertain receipt can be resolved by matching its
|
||||
provider message ID and Paperclip publication header, or by an operator confirming
|
||||
that it was not sent. The latter marks it failed; any resend is a new explicit
|
||||
action. Do not change an idempotency key just because a request timed out.
|
||||
|
||||
## Disconnect and diagnostics
|
||||
|
||||
Reconnect preserves inbox and task identity. Pause stops intake and sending.
|
||||
Disconnect archives the local endpoint and removes its credential bindings,
|
||||
unreferenced vaulted credentials, and only the webhook/runtime key created by
|
||||
Paperclip. It never deletes the provider inbox or task history. If a revoked key
|
||||
prevents provider cleanup, local disconnection still completes and reports that
|
||||
Paperclip's provider registrations need cleanup in AgentMail.
|
||||
|
||||
Connection settings show state, receiving mode, catch-up time and errors. Tasks
|
||||
show publication failures and uncertain delivery resolution. Delivery admission,
|
||||
message processing and agent wakeup are separate from provider delivery and model
|
||||
startup; live latency measurements must distinguish those stages.
|
||||
|
||||
## Verification and live qualification
|
||||
|
||||
Deterministic coverage lives in `server/src/__tests__/agentmail-api.test.ts`,
|
||||
`server/src/__tests__/email-channels.integration.test.ts`, and
|
||||
`tests/e2e/agentmail.spec.ts`. It exercises real database transactions with a fake
|
||||
provider, plus browser setup and explicit task email actions.
|
||||
|
||||
Before labeling an installation live-qualified, use a disposable inbox and an
|
||||
approved test recipient. In each transport mode, receive a message, verify one
|
||||
task and one wake, send an explicit reply, and verify provider threading and
|
||||
delivery. Also disconnect/reconnect, interrupt receiving, and verify catch-up.
|
||||
Record provider message IDs and timestamps without copying credentials. Compare
|
||||
the durable delivery `received_at` with the wake request time separately from
|
||||
provider transit time and model startup. Automated fixtures do not constitute
|
||||
live provider qualification.
|
||||
|
||||
Provider references: [inboxes](https://docs.agentmail.to/inboxes),
|
||||
[webhook verification](https://docs.agentmail.to/webhook-verification),
|
||||
[idempotency](https://docs.agentmail.to/idempotency),
|
||||
[message listing](https://docs.agentmail.to/api-reference/inboxes/messages/list),
|
||||
[reply API](https://docs.agentmail.to/api-reference/inboxes/messages/reply).
|
||||
|
||||
### Sandbox execution
|
||||
|
||||
AgentMail runs in the Paperclip control plane using its vaulted credentials. It
|
||||
is a REST connection, not a local-stdio MCP server. The connection health check
|
||||
validates the key against AgentMail; it does not launch a local command or discover
|
||||
MCP tools.
|
||||
|
||||
Agents in Daytona and other sandbox environments use the same task email actions.
|
||||
The sandbox callback bridge allows inbox discovery, bound-thread reads, delivery
|
||||
reads, and explicit sends. The controller enforces company, inbox, task/run, and
|
||||
action-policy checks. Mailbox setup, credential inspection, reconnect, and manual
|
||||
delivery resolution remain outside that sandbox API surface. Native runners use
|
||||
the assigned AgentMail tools through their run-bound tool channel. Neither path exposes the
|
||||
AgentMail provider key to the sandbox.
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
# AI Connections
|
||||
|
||||
AI accounts use the existing Apps/Connections substrate. Manage them at
|
||||
`/:company/apps`; select their use beside an agent's harness/model settings.
|
||||
Onboarding, new-agent setup, account creation/reconnect, and inline task requests
|
||||
reuse `AdapterLoginPanel`, its existing login controllers, and `AdapterLoginChrome`.
|
||||
Onboarding and new-agent setup retain upstream's `SavedProviderKeySelect` and
|
||||
`useSavedProviderKeys`, including saved-key references and account-specific Codex
|
||||
homes. Managed default/shared accounts are additional choices in that same
|
||||
selector. Selecting “Sign in to another account” survives background refreshes;
|
||||
Claude authorization paste keeps upstream's immediate Connecting feedback.
|
||||
|
||||
Storybook's simulated controllers and page annotations do not run in the app.
|
||||
|
||||
## Compatibility and selection
|
||||
|
||||
The shared `AI_CONNECTION_CAPABILITIES` contract defines these combinations:
|
||||
|
||||
| Provider | Sign-in method | Existing harness |
|
||||
| --- | --- | --- |
|
||||
| Claude / Anthropic | Claude subscription token or Anthropic API key | Claude |
|
||||
| OpenAI | ChatGPT/Codex subscription or OpenAI API key | Codex |
|
||||
| OpenRouter | API key | OpenCode, with an `openrouter/` model |
|
||||
| Grok / xAI | Grok subscription or xAI API key | Grok |
|
||||
|
||||
Native runner supports the corresponding existing Codex, OpenCode, and Claude
|
||||
ACP profiles. Connections creation and reconnect mount `AgentProviderConnection`,
|
||||
the same provider tiles, method controls, API entry, and `AdapterLoginPanel` used
|
||||
by agent setup. Supported sandbox environments use onboarding's existing browser
|
||||
sign-in controllers. Self-hosted installations use the shared terminal sign-in
|
||||
instructions described below and require no sandbox. Environment selection does
|
||||
not change agent execution settings.
|
||||
API keys are validated against fixed provider endpoints; redirects
|
||||
and caller-supplied validation URLs are rejected.
|
||||
|
||||
`runtimeConfig.aiConnection` contains `provider`, `mode`, and `method`. For responsible-user selections, `method` is a legacy wire hint retained for rolling upgrades; the resolver uses the selected account’s actual method:
|
||||
|
||||
- `responsible_user`: resolve the run's responsible user's personal provider default, using that account's subscription or API key. The `method` hint does not restrict the responsible user's account.
|
||||
- `shared`: use the named `connectionId` and `grantId`, with audience and agent
|
||||
access checks.
|
||||
- `delegated`: retained only to read legacy bindings. It cannot bypass human
|
||||
access; a personal credential remains available only for its owner's tasks.
|
||||
New configuration offers personal defaults or shared accounts.
|
||||
|
||||
“Which humans can use this credential?” is the sole permission for whose work
|
||||
can use the account. “Just me” means the personal owner; shared accounts allow
|
||||
selected company members or every company member. The separate agent-access
|
||||
setting determines which agents can use it. There is no additional AI agent
|
||||
authorization, and old delegation records do not override the human audience.
|
||||
|
||||
A connection choice never changes the harness, model, or provider routing.
|
||||
Changing those separately may make a binding incompatible; saving then requires
|
||||
a compatible choice. Agent configuration cannot grant access to another account.
|
||||
|
||||
Personal defaults are unique per company, user, and provider. A Claude bot can use one user’s subscription and another user’s API key without changing its harness or model. Explicit shared selections remain pinned to the selected account and method.
|
||||
The first successful personal connection sets a default only when none exists.
|
||||
The additive `ai_provider_defaults` table preserves the legacy per-method preferences. Migration selects each user’s most recently updated provider preference (including unavailable accounts), and rerunning it never overwrites a provider default. New writes maintain the legacy table for older servers. A database trigger propagates older servers’ explicit default updates to the provider default. Inserting an additional method default does not replace an existing provider default.
|
||||
|
||||
Revocation retains the unavailable default; connecting another account does not
|
||||
silently replace it. Change it explicitly on the account detail page.
|
||||
|
||||
## Storage and API
|
||||
|
||||
AI connections pair `connectionPurpose: ai` with `transport: runtime_auth`.
|
||||
Database checks and the shared discriminator enforce the pair. These entries
|
||||
cannot participate in tool discovery, MCP gateways, execution, or channels.
|
||||
Anthropic offers Claude subscription and Claude API key; the unsupported duplicate REST API option is excluded. Catalog
|
||||
validation also pairs AI metadata with runtime authentication and rejects unsupported
|
||||
sign-in methods. Provider artwork and source provenance live in
|
||||
`ui/public/brands/apps/manifest.json`; OpenRouter uses its official sign-in assets,
|
||||
and OpenAI/Grok reuse the repository's pinned Lobe Icons source and license.
|
||||
|
||||
Provider/method metadata lives in `config.ai`. Credentials live on the existing
|
||||
grant through encrypted vault secret references, with existing consumer bindings.
|
||||
Safe provider-reported account identity is optional; secret references, tokens,
|
||||
and authentication paths are never account labels.
|
||||
|
||||
Company-scoped `/api/companies/:companyId/ai-connections` operations provide list,
|
||||
API-key creation/reconnect, personal defaults, completed login references, and
|
||||
active-run attribution. Existing Connections operations handle naming, access,
|
||||
and revocation. Mutation authorization is enforced server-side. OpenAPI documents the new board-only
|
||||
operations. Agent-originated configuration and environment tests resolve the
|
||||
authenticated request’s responsible user; an agent ID is never a personal-account
|
||||
owner. A missing responsible identity blocks personal-default resolution.
|
||||
|
||||
Subscription login attempts retain their company, owner, method, access intent,
|
||||
and reconnect target in the existing durable authentication session. Duplicate
|
||||
completion returns the same connection/grant. Abandoned or expired attempts cannot
|
||||
save a healthy connection. Reconnect preserves the connection ID, bindings,
|
||||
customized name, and access settings. A completed connection remains even if
|
||||
subsequent agent creation fails or is cancelled.
|
||||
|
||||
## Runtime isolation
|
||||
|
||||
`prepareManagedAiRuntime` is shared by runs, environment tests, and adoption.
|
||||
It checks responsible identity, membership, compatibility, connection health,
|
||||
human audience and agent installation before reading credentials.
|
||||
Missing credentials produce an actionable configuration failure; responsible-user
|
||||
task runs use the existing connection-request interaction, marked `purpose: ai`.
|
||||
A runtime-auth request cannot satisfy, reuse, or supersede a tool request for
|
||||
the same provider. AI-only methods are excluded from agent tool discovery.
|
||||
|
||||
Each invocation receives a private authentication home and only the selected
|
||||
grant's credentials. Inherited credential variables are cleared. Conflicting
|
||||
project authentication and provider-routing overrides are rejected. Managed
|
||||
failure cannot reactivate host or legacy credentials.
|
||||
|
||||
Subscription invocations take a grant-scoped transaction advisory lease. The
|
||||
reserved database client keeps one transaction open until cleanup, including on
|
||||
transaction-pooling proxies such as PgBouncer. Session-level advisory locks must
|
||||
not be used here: a pooled connection can return to a different backend for
|
||||
cleanup and leave the original lock behind. The lease transaction disables its
|
||||
idle timeout and contains no application data writes; cleanup rolls it back.
|
||||
Two
|
||||
different users' grants can run concurrently; a second invocation of the same
|
||||
subscription receives a retryable busy response while it is in use. Refreshes
|
||||
are merged only into the originating active grant, with reconnect/revocation
|
||||
version checks. Temporary homes are removed on normal completion or failure.
|
||||
|
||||
Session reuse includes grant identity, responsible user, and credential
|
||||
generation. A changed identity starts a fresh provider session. Managed native
|
||||
executions use per-turn lifecycle cleanup; a suspended native execution whose
|
||||
credential identity changed must restart as a new execution.
|
||||
|
||||
Revocation blocks new invocations and refresh persistence. A running provider
|
||||
process may already hold credentials. The revoke confirmation lists attributed
|
||||
active runs and exposes the existing Stop action; it does not promise immediate
|
||||
provider-side revocation.
|
||||
|
||||
## Legacy adoption
|
||||
|
||||
Migration `0273` indexes only explicitly owned personal secrets with a recognized
|
||||
provider/method and matching agent configuration. It keeps original secret
|
||||
references and leaves every agent's legacy authentication unchanged. Reconnecting
|
||||
an indexed account creates a private grant credential instead of rotating the
|
||||
legacy secret. Subsequent reconnects rotate that private credential. Unknown
|
||||
ownership and filesystem-only subscriptions remain unresolved. The migration is
|
||||
repeatable and does not classify unknown credentials as company-shared.
|
||||
|
||||
Imported accounts initially need validation. Agent settings show “Existing
|
||||
authentication — not managed by Connections” until adoption. The adoption
|
||||
confirmation names the binding and affected agent. Saving runs a provider hello
|
||||
test in that agent's environment before replacing authentication. After adoption,
|
||||
the server preserves the managed binding and will not restore legacy fallback.
|
||||
|
||||
## Local subscription sign-in
|
||||
|
||||
Local installations do not need a sandbox to connect a subscription. Connections,
|
||||
onboarding, and agent setup share `LocalProviderLoginInstructions` and
|
||||
`useLocalAiLogin`. In local-trusted mode, Claude checks the operator’s existing
|
||||
Claude Code login. Authenticated self-hosted users instead get a separate
|
||||
`CLAUDE_CONFIG_DIR` for `claude auth login`; checking and saving only read that
|
||||
attempt’s credential files, never the server operator’s account or Keychain.
|
||||
|
||||
Codex and Grok start a separate terminal sign-in for each connection or reconnect.
|
||||
The shared component shows a server-generated command with a fresh `CODEX_HOME`
|
||||
or `GROK_HOME`. Codex uses file credential storage in that home and `login --device-auth`, so
|
||||
signing in from another computer does not depend on a localhost callback. The home is never
|
||||
seeded with the operator's existing login: copying a rotating refresh token would
|
||||
allow managed runs to invalidate credentials still used by legacy agents or the
|
||||
operator's terminal. The user completes browser sign-in from that command, then
|
||||
clicks Connect. This does not require a sandbox or change the host login.
|
||||
|
||||
Attempts reuse `adapter_auth_sessions`, binding company, owner, provider, access
|
||||
intent, reconnect target, and a 30-minute expiry. Validation and completion are
|
||||
serialized; duplicate completion returns the saved connection. Restart retains
|
||||
the attempt. Cancellation and expiry remove the attempt home, and the startup/
|
||||
periodic cleanup sweep retries expired directories. Successful completion persists
|
||||
credentials to the encrypted grant and removes the temporary login home. Refreshes
|
||||
subsequently update only that grant. Reconnect preserves IDs and access settings.
|
||||
|
||||
Starting an isolated attempt requires normal company-scoped AI-connection creation
|
||||
permission. Checks, completion, cancellation, and resumption are owner-bound.
|
||||
Authenticated users cannot import host credentials or use another user’s attempt.
|
||||
Claude Keychain reads remain limited to the explicit local-trusted default-home import. A failed verification creates
|
||||
no healthy connection. Preview-era Codex/Grok managed connections without the
|
||||
isolated-subscription marker require reconnect before another managed execution;
|
||||
unmanaged legacy agents retain their existing authentication paths.
|
||||
|
||||
## Verification
|
||||
|
||||
`server/src/__tests__/ai-connections.test.ts` exercises storage, isolation,
|
||||
defaults, human audiences, agent access, reconnect races, refresh ownership,
|
||||
subscription locking, migration replay, and redacted API failures against a real
|
||||
embedded database. Existing login, adapter, tool, and channel suites cover their
|
||||
shared integration paths. The onboarding tests cover managed reuse and keeping a
|
||||
successfully connected account after failed agent creation.
|
||||
|
||||
The [Storybook review index](http://localhost:6116/?path=/story/ai-connections-review--review-index)
|
||||
retains deterministic authentication states and interaction checks. Run
|
||||
`pnpm build-storybook`, then
|
||||
`pnpm exec playwright test --config tests/ai-connections-review/playwright.config.ts`.
|
||||
Also run token gates, repository typecheck, tests, and build before handoff.
|
||||
Live connect → reuse → run → reconnect verification still requires valid provider
|
||||
credentials and a supported login/runtime environment; fixtures do not prove it.
|
||||
|
||||
For an isolated running test drive, also run:
|
||||
|
||||
```sh
|
||||
AI_CONNECTIONS_TEST_COMPANY_ID=<company-id> pnpm exec playwright test --config tests/ai-connections-app/playwright.config.ts
|
||||
```
|
||||
|
||||
Set `AI_CONNECTIONS_TEST_URL` when the test drive uses a port other than 3100.
|
||||
|
||||
These browser checks exercise the production list/detail pages, rejected API-key
|
||||
validation, cancellation, focus restoration, and adoption without saving agent
|
||||
changes. They submit an explicitly invalid fixture key and do not prove successful
|
||||
authentication with a live account.
|
||||
|
||||
### Local sign-in checks
|
||||
|
||||
Local subscription screens share the same credential check on entry and when the
|
||||
window regains focus. Waiting screens also poll until sign-in verifies. A successful
|
||||
check shows the account is signed in; only **Connect** creates or reconnects the grant.
|
||||
In local-trusted mode, Claude checks the local operator’s Claude Code login.
|
||||
Authenticated Claude users, plus all Codex and Grok users, check only their
|
||||
connection-specific login home. The health response selects credential isolation,
|
||||
not whether a self-hosted user may sign in.
|
||||
|
||||
Leaving and returning to a local sign-in screen resumes its active attempt. Navigation
|
||||
does not delete a directory referenced by a copied command. **Start sign-in again**
|
||||
explicitly cancels the old attempt; abandoned attempts expire after 30 minutes.
|
||||
Commands create their directory if necessary, and completed/expired attempts are
|
||||
cleaned up through the existing lifecycle.
|
||||
|
||||
### Disposable live inline-repair test
|
||||
|
||||
The normal app test configuration excludes `*.live.spec.ts`. To run the destructive
|
||||
inline-repair scenario, set `AI_REPAIR_TEST_ALLOW_DESTRUCTIVE=1` and use a separate
|
||||
loopback `local_trusted` instance. Set `AI_REPAIR_TEST_DISPOSABLE_MARKER` to a fresh
|
||||
32-character lowercase hexadecimal value. The company, single Codex agent, single
|
||||
personal OpenAI API connection, and issue must all be named `AI Repair QA <marker>`
|
||||
(the issue uses that title). Supply their IDs with `AI_CONNECTIONS_TEST_COMPANY_ID`,
|
||||
`AI_REPAIR_TEST_CONNECTION_ID`, and `AI_REPAIR_TEST_ISSUE_ID`, and the disposable
|
||||
provider key with `AI_REPAIR_TEST_KEY`. The test verifies these boundaries before
|
||||
revoking credentials or submitting work. Delete the disposable instance and revoke
|
||||
its provider key after the test; failed tests may leave a paused task for inspection.
|
||||
|
||||
Authenticated public deployments must configure a trusted runtime host (`PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` or `PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST`) before offering server-host subscription login, matching the local stdio runtime boundary. Health reports this capability so setup can offer a supported environment or API key instead of an unusable terminal command. Private authenticated self-hosted instances support isolated local login without that extra setting. Isolated Claude credential files must be private, owned by the server user, bounded, and free of symlinks.
|
||||
|
|
@ -35,6 +35,13 @@ Paperclip resolves short-lived tokens at invocation time. Before writing a
|
|||
connector, read [Identity vs. connections](./README.md#identity-vs-connections)
|
||||
for the P1/P2/P3 boundary and the D7 standing rule.
|
||||
|
||||
AI provider credentials use the same vault, applications, grants, installations,
|
||||
and delegation model with `connectionPurpose: ai` and `transport: runtime_auth`.
|
||||
They authenticate provider execution and never enter MCP discovery or tool/channel
|
||||
execution. Extend the provider's existing catalog entry with typed AI methods;
|
||||
reuse the existing login controllers. See [AI Connections](./AI-CONNECTIONS.md)
|
||||
for compatibility, personal defaults, resolver isolation, and legacy adoption.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Mental model and support matrix](#mental-model-five-independent-axes)
|
||||
|
|
@ -42,6 +49,7 @@ for the P1/P2/P3 boundary and the D7 standing rule.
|
|||
- [Secret storage and lifecycle](#secret-storage-and-lifecycle)
|
||||
- [Current access defaults](#current-default-access-policy)
|
||||
- [Golden-path agent tutorial](#golden-path-agent-tutorial)
|
||||
- [Connection UX and user journeys](#connection-ux-and-user-journeys)
|
||||
- [AppDefinition field reference](#appdefinition-field-reference)
|
||||
- [Troubleshooting](#troubleshooting-and-failure-classification)
|
||||
- [Definition of done](#definition-of-done)
|
||||
|
|
@ -107,7 +115,7 @@ chooses all five axes below.
|
|||
| Authentication | `oauth`, `api_key`, `none` | How does the provider authorize requests? |
|
||||
| OAuth client ownership | `dcr`, `customer`, `platform_shared`, `platform_provisioned` | Who supplies and controls the OAuth client registration? |
|
||||
| Credential source | `paperclip_vault`, reviewed `vercel_connect` | Where does durable provider credential material live? |
|
||||
| Grant identity | `organization`, `user` | Does the credential act for the company or one person? |
|
||||
| Grant identity | `organization`, `user`, `agent` | Does the credential act for the company, one person, or one dedicated agent? |
|
||||
|
||||
These axes produce combinations such as:
|
||||
|
||||
|
|
@ -119,6 +127,8 @@ These axes produce combinations such as:
|
|||
- Remote MCP + no auth + required tenant field: Shopify.
|
||||
- Remote MCP + Paperclip-managed OAuth client + per-user grant: Google
|
||||
Workspace MCP previews.
|
||||
- Remote MCP + Paperclip-managed OAuth client + personal or dedicated-agent
|
||||
grant: GitHub. See [GitHub managed connection](./GITHUB.md).
|
||||
- Local stdio MCP + approved command template: the Google Sheets robot flow and
|
||||
development fixtures.
|
||||
- REST API parent + provider-specific child-session bridge: Composio. This is a
|
||||
|
|
@ -136,6 +146,14 @@ These axes produce combinations such as:
|
|||
transport is a REST API. Most current API-key catalog entries authenticate a
|
||||
remote MCP server.
|
||||
|
||||
Anthropic accounts use the `runtime_auth` AI connection methods. Its obsolete
|
||||
`api-key` REST tool method is no longer offered. Existing unsupported REST tool
|
||||
connections fail health and catalog checks with HTTP 422 and
|
||||
`tool_connection_transport_unsupported`; they never use local stdio templates
|
||||
or report a successful MCP probe. Add the provider through its supported account
|
||||
flow, then remove the obsolete connection. This does not transfer credentials
|
||||
or grants automatically.
|
||||
|
||||
For `mcp_remote`, header credentials and secret-bearing generated URLs have the
|
||||
complete generic runtime path. The schema also names `query`, `body_json`, and
|
||||
`env` key placements for specialized transports, but accepting a value in the
|
||||
|
|
@ -274,17 +292,23 @@ The current product behavior is encoded by `recommendedDefaultsForApp` in
|
|||
`packages/shared/src/app-definitions.ts`:
|
||||
|
||||
- Every discovered action is enabled during successful setup.
|
||||
- S1-S3 methods default their actions to **Allowed**, including writes.
|
||||
- S4 methods default `write` and `destructive` actions to **Ask first**.
|
||||
- Every active action defaults to **Allowed**, including `write` and
|
||||
`destructive` actions, for every connection method.
|
||||
- Permanently blocked provider actions stay disabled.
|
||||
- Provider/schema-specific changed-tool quarantine remains a separate catalog
|
||||
concern; do not turn writes Off as a substitute for correct risk
|
||||
classification.
|
||||
|
||||
If a destructive provider cannot be safe with those defaults, classify the
|
||||
method S4 or add a narrowly reviewed provider policy with tests. Do not hide a
|
||||
dangerous tool by misclassifying it as read, and do not silently change global
|
||||
defaults in a provider PR.
|
||||
This is an opt-in restriction model. Finishing a connection is still limited to
|
||||
a board user with connection-configuration access, commits the selected action
|
||||
IDs to an auditable profile, and leaves **Ask first** available for any action.
|
||||
The open default changes the initial policy; it does not create a route around a
|
||||
policy the operator has applied.
|
||||
|
||||
If a destructive provider cannot be safe with those defaults, add a narrowly
|
||||
reviewed provider policy with tests. Do not hide a dangerous tool by
|
||||
misclassifying it as read, and do not silently change global defaults in a
|
||||
provider PR.
|
||||
|
||||
## Golden-Path Agent Tutorial
|
||||
|
||||
|
|
@ -417,6 +441,122 @@ connection work or enforce a real tenant boundary. Follow these rules:
|
|||
label is not enforcement. The provider, gateway, wrapper, or managed header/
|
||||
query projection must enforce the boundary.
|
||||
|
||||
#### Connector-provided skills and tools
|
||||
|
||||
Connectors may contribute bundled skills with optional native tools. Keep provider-specific
|
||||
instructions out of the universal Paperclip skill and provider-specific tools
|
||||
out of the universal runner catalog. Use the trusted connector contribution
|
||||
registry in `server/src/services/connector-runtime.ts`; AgentMail is the first
|
||||
consumer. This registry describes bundled server implementations, not executable
|
||||
code or skill URLs supplied by a credential or external message.
|
||||
|
||||
For each contribution, declare its connector key, bundled skill, namespaced tool
|
||||
definitions, resource-assignment resolver, and execution handler. Use names such
|
||||
as `agentmail_send` rather than extending core tools with provider-specific
|
||||
branches. Existing MCP connectors continue to use their normal MCP tool catalog;
|
||||
they do not need a duplicate native wrapper just to supply a skill.
|
||||
|
||||
**Resolve eligibility from current assignments and access.** An AgentMail account
|
||||
credential alone does not give an agent email capabilities. An active inbox
|
||||
assigned to that agent does, provided both the inbox connection and saved
|
||||
credential access remain authorized and the experimental chat-connector flag is
|
||||
on. Other connectors must define an equally concrete assignment rule. Keep every
|
||||
lookup company-scoped. Revoked grants, disabled connections, removed assignments,
|
||||
and experimental gates must remove the contribution. Fail closed on lookup errors.
|
||||
|
||||
**Install skills transparently through the existing runtime skill path.** Merge
|
||||
system-managed contributions with the agent's chosen skills for each run, without
|
||||
writing them into its saved skill preferences. Deduplicate multiple resources
|
||||
from the same connector into one skill. Include only authorized resource context,
|
||||
never provider secrets; treat resource values as data. Supply the short skill
|
||||
description for discovery and keep detailed instructions in the skill. The same
|
||||
resolved set must reach local CLI adapters, sandbox adapters, and native runners.
|
||||
Adapters with isolated skill delivery receive the bundle. Adapters that install
|
||||
into shared user directories receive the same assigned skill in the run prompt,
|
||||
including resumed turns, without writing connector files into that directory.
|
||||
Manual skill-sync operations must also exclude automatic connector bundles.
|
||||
The agent Skills page should identify automatic contributions and explain that
|
||||
assignment controls them; they are not independently enabled/disabled there.
|
||||
|
||||
**Bind tools to the same resolved skill assignment.** Native sessions advertise
|
||||
only contributions present in their pinned runtime skill bundle. Include skill
|
||||
content, resource assignments, and tool revisions in session compatibility so a
|
||||
changed assignment cannot reuse stale declarations. Revalidate live assignment,
|
||||
company/task/run authority, and configured action policy on every execution.
|
||||
Removing a tool from discovery alone is not revocation enforcement. Retained
|
||||
provider sessions and previously issued calls must fail after access is revoked.
|
||||
|
||||
**Avoid shared runtime contamination.** Do not install assignment-specific skills
|
||||
into a company-wide or user-wide runtime home. Use immutable skill bundles and
|
||||
scoped runtime directories. Codex CLI connector runs use a separate home per
|
||||
agent and connector-skill revision, seeded from the selected model credential
|
||||
home. Disconnecting returns to a runtime without those skills; another agent must
|
||||
never inherit them. Preserve explicit model identity and normal session recovery.
|
||||
|
||||
Required tests cover no assignment, credential access without a resource,
|
||||
authorized assignment, multiple resources with one skill, cross-company access,
|
||||
revocation during a retained run, disabled flags/connections, and reassignment.
|
||||
Verify skill installation and removal in both CLI/sandbox and native execution,
|
||||
including tool discovery, runtime cache changes, and absence of provider secrets.
|
||||
Exercise an actual connector operation through the contributed tool, not just
|
||||
its declaration. Record which runtime paths were tested live versus deterministically.
|
||||
|
||||
#### Connection UX and user journeys
|
||||
|
||||
Design the whole journey, from finding the app to doing useful work with an
|
||||
agent. A successful credential exchange is only one step. Describe who the
|
||||
user is, where they start, what they want to accomplish, and where they will
|
||||
see the result. Walk through first use, returning use, and recovery from a
|
||||
failed action. For messaging connections, cover both agent-initiated work and
|
||||
incoming messages that start or continue work.
|
||||
|
||||
**Separate connecting from assigning an agent a resource.** First configure
|
||||
who can use the connection and authenticate with the provider. If the feature
|
||||
also assigns a resource to a specific agent, offer a second wizard from the
|
||||
connection's Permissions view after the connection is saved. Give its entry
|
||||
point a prominent, concrete action name. For example, AgentMail uses “Give an
|
||||
agent an email address,” followed by Agent → Email address → Review. Reuse the
|
||||
saved credential; do not ask for the API key again. Use the existing numbered
|
||||
step pattern, sensible defaults, Back and Cancel, and a clear completion state.
|
||||
Do not add a second wizard when there is no separate assignment to configure.
|
||||
|
||||
Let the operator search eligible company agents, including agents not yet on
|
||||
the connection's allowed list. When assigning a resource also grants connection
|
||||
access, make that consequence clear and persist the grant through the existing
|
||||
access machinery. Respect the operator's authority to grant access, and show
|
||||
the selected agent's avatar and name.
|
||||
|
||||
**Use the minimum text needed to make the next action clear.** Prefer familiar
|
||||
controls and precise labels over explanatory paragraphs. Remove repeated
|
||||
headings, redundant access summaries, implementation details, and reassurance
|
||||
that does not help the user decide or act. Keep necessary warnings, meaningful
|
||||
consequences, and actionable errors. Put optional expert settings under a
|
||||
collapsed Advanced disclosure. Link to provider-owned administration, such as
|
||||
AgentMail allowlists, rather than rebuilding it in Paperclip.
|
||||
|
||||
**Keep ongoing interactions in Paperclip tasks.** Connections are where users
|
||||
set up access and configuration; tasks are where they work with agents. Design
|
||||
what happens after setup: how an agent invokes the connection, where incoming
|
||||
work lands, how follow-ups stay associated with that work, and how users see
|
||||
success or recover from failure. Avoid introducing a separate mailbox or
|
||||
provider dashboard as the primary interaction surface.
|
||||
|
||||
Use rich cards in the task feed when they make external activity easier to
|
||||
understand. An email card, for example, can show the sender, recipients, body,
|
||||
attachments, and delivery state. Keep external activity distinguishable from
|
||||
internal discussion; a task comment or agent progress update must not imply
|
||||
that an external action occurred. Reuse existing task-feed components and
|
||||
preserve one visible record per external event.
|
||||
|
||||
**Make interactive Storybooks for setup and actual use.** Include the catalog
|
||||
card, access and credential steps, any agent-resource wizard, and the task
|
||||
journeys after setup. Provide a clickable walkthrough plus focused stories for
|
||||
important steps, loading, errors, and recovery. Use realistic fixtures and
|
||||
clearly label simulated actions. Reuse production components as implementation
|
||||
lands, and replace obsolete stories so the examples describe the current
|
||||
experience. Storybooks support design review and deterministic interaction
|
||||
tests; they do not replace a real-provider browser test.
|
||||
|
||||
### Phase 4: Add official branding before exposing the app
|
||||
|
||||
Every store-visible provider needs an official local mark. A letter tile is
|
||||
|
|
@ -681,7 +821,11 @@ At minimum, add or update tests in these layers:
|
|||
declared.
|
||||
- Finish setup resumes the exact draft using `resumeConnectionId`.
|
||||
- Optional customer OAuth details stay folded when automatic OAuth exists.
|
||||
- Setup success leads to the connection's Test page.
|
||||
- Setup success leads to the connection's Test page, or to Permissions when a
|
||||
separate agent-resource assignment is the next step. Follow the
|
||||
[connection UX guidance](#connection-ux-and-user-journeys).
|
||||
- Interactive Storybooks cover setup and ongoing task interactions, including
|
||||
relevant failure states; the walkthrough matches the implemented journey.
|
||||
- Missing images fall back at runtime, while manifest acceptance still fails
|
||||
missing branding.
|
||||
|
||||
|
|
@ -742,7 +886,8 @@ Walk the user path:
|
|||
it returns to
|
||||
`?source=<slug>&resume=<connection-id>` without creating another draft.
|
||||
7. Complete setup. Confirm the connection is active/healthy and opens
|
||||
`/<company-prefix>/apps/<connection-id>/test`.
|
||||
`/<company-prefix>/apps/<connection-id>/permissions`, then use the action's
|
||||
**Test** button.
|
||||
|
||||
For OAuth, the instance callback must be browser-reachable and must match the
|
||||
provider registration. Loopback HTTP is acceptable only when provider and
|
||||
|
|
@ -956,7 +1101,7 @@ Suggested PR verification block:
|
|||
| `consoleLinks` | Official registration, key, settings, and docs destinations. |
|
||||
| `warnings` | Plan, preview, admin, financial, production-data, or destructive-action caveats. |
|
||||
| `variants` | Legacy/simple variant metadata. Prefer explicit methods plus `capabilityProfile` for materially different endpoints/auth. |
|
||||
| `riskTier` | S1-S4 provider/method sensitivity. Drives recommended policy defaults. |
|
||||
| `riskTier` | S1-S4 provider/method sensitivity used for review and validation. |
|
||||
| `requiredResourceFilters` | Reviewed resource boundaries. Must be backed by enforcement, not only copy. |
|
||||
| `credentialSources.vercelConnect` | Reviewed services, principal modes, scopes, and header projection for the Vercel exception. |
|
||||
|
||||
|
|
@ -1149,7 +1294,7 @@ Capture:
|
|||
`requiredResourceFilters` only when their documented semantics apply.
|
||||
- `setupPrerequisite`, `warnings`, `guidanceMd`, and `consoleLinks`: everything
|
||||
the operator must know before credentials or consent.
|
||||
- `riskTier`: the method-level S1-S4 tier that drives central access defaults.
|
||||
- `riskTier`: the method-level S1-S4 tier used for review and validation.
|
||||
- `availability`: whether the connection is usable on this instance and the
|
||||
precise reason when it is not.
|
||||
|
||||
|
|
@ -1190,8 +1335,8 @@ Risk classes:
|
|||
| Risk | Examples | Default |
|
||||
| --- | --- | --- |
|
||||
| `read` | Search, list, fetch metadata/content inside allowed resources. | Active when profile includes the app or read risk level. |
|
||||
| `write` | Create issue, add comment, update status, append block, trigger redeploy. | Allowed for S1-S3 under the current product default; ask-first for S4. |
|
||||
| `destructive` | Delete, refund, cancel production deployment, send external message, broad tenant mutation. | Allowed for S1-S3 and ask-first for S4 under the current default. A provider with meaningful destructive capability should normally be S4 or receive a reviewed explicit policy. |
|
||||
| `write` | Create issue, add comment, update status, append block, trigger redeploy. | Allowed under the current new-connection default. Operators may narrow individual actions. |
|
||||
| `destructive` | Delete, refund, cancel production deployment, send external message, broad tenant mutation. | Allowed under the current new-connection default. A provider with meaningful destructive capability should receive an explicit security review and may receive a narrower provider policy. |
|
||||
|
||||
Changed-action quarantine is available when a connection sets
|
||||
`quarantineNewEntries: true`. Use it for providers whose catalog can change
|
||||
|
|
@ -1204,6 +1349,11 @@ connection actually enables it.
|
|||
|
||||
The wizard path comes from auth mode and transport:
|
||||
|
||||
These paths describe authentication and provisioning. Apply the
|
||||
[connection UX guidance](#connection-ux-and-user-journeys) to the user-facing
|
||||
sequence: choose access before authentication, then configure any per-agent
|
||||
resource through a separate wizard on the saved connection.
|
||||
|
||||
| Auth mode | Operator path | Stored result |
|
||||
| --- | --- | --- |
|
||||
| OAuth | Gallery card -> Connect -> vendor consent -> callback -> configure filters -> health/catalog -> access defaults. | OAuth token material in `company_secrets`; connection metadata redacted. |
|
||||
|
|
@ -1239,8 +1389,8 @@ Recommended defaults for a new catalog entry:
|
|||
|
||||
- Use the central `recommendedDefaultsForApp` policy. Do not invent a provider
|
||||
default in UI code.
|
||||
- S1-S3 actions default Allowed. S4 writes and destructive actions default Ask
|
||||
first.
|
||||
- All active actions default Allowed for every method tier. Operators can move
|
||||
individual actions to Ask first or Off after setup.
|
||||
- Classify a method S4 when its normal catalog includes payments, external
|
||||
sends, refunds, production deployment, deletion, tenant-wide administration,
|
||||
or comparable high-impact mutations.
|
||||
|
|
@ -1257,7 +1407,8 @@ Recommended defaults for a new catalog entry:
|
|||
- Catalog discovery produces the expected actions and the declared changed-tool
|
||||
behavior.
|
||||
- An allowed read call succeeds through the gateway.
|
||||
- A write call matches the method tier: Allowed for S1-S3, Ask first for S4.
|
||||
- A write call is Allowed by the new-connection default unless an explicit
|
||||
provider or operator policy narrows it.
|
||||
- A blocked/quarantined action, when declared, cannot be listed or invoked by
|
||||
an agent.
|
||||
- Revocation removes tools and blocks execution immediately.
|
||||
|
|
@ -1511,7 +1662,7 @@ Copy this section into a connector proposal or implementation issue.
|
|||
- Connect evidence:
|
||||
- Catalog evidence:
|
||||
- Allowed read:
|
||||
- Governed write (Allowed for S1-S3, Ask first for S4):
|
||||
- Governed write (Allowed by default; operator policy may narrow it):
|
||||
- Denied/quarantined case:
|
||||
- Revoke:
|
||||
- Audit:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
# GitHub managed connection
|
||||
|
||||
GitHub is a Paperclip Cloud-managed GitHub App connection with an advanced PAT
|
||||
compatibility method. Cloud owns the fixed public OAuth callback and signed
|
||||
webhook inbox; provider tokens are sealed to the enrolled instance and stored
|
||||
only in its existing encrypted secret system.
|
||||
|
||||
## Self-hosted setup
|
||||
|
||||
The Access step uses **Continue** to open the local setup screen.
|
||||
**Continue to GitHub** on that screen starts the provider handoff. The first
|
||||
button does not imply that the browser is leaving Paperclip yet.
|
||||
|
||||
A self-hosted instance needs one Paperclip Cloud approval before its first
|
||||
managed connection. After approval, setup returns to step 2 and continues to
|
||||
GitHub without another instance approval or a service restart.
|
||||
|
||||
If an unapproved enrollment link expires, return to setup and select
|
||||
**Continue**. Paperclip asks the server for a valid link. The server reuses a
|
||||
live pending enrollment or replaces an expired one; this does not revoke or
|
||||
repeat an existing instance approval.
|
||||
|
||||
## Identity resolution
|
||||
|
||||
Every MCP call, `gh` invocation, native Git operation, checkout, health check,
|
||||
and webhook binding uses the same order:
|
||||
|
||||
1. An active dedicated GitHub grant for the current agent.
|
||||
2. The active personal GitHub grant owned by the run's `responsibleUserId`.
|
||||
3. For automated work without a responsible user, a personal grant only when
|
||||
an existing standing delegation names the agent.
|
||||
4. Legacy `GH_TOKEN`/`GITHUB_TOKEN` only when no managed GitHub connection is
|
||||
configured for the company.
|
||||
|
||||
An unavailable or ambiguous managed identity fails visibly. It never falls
|
||||
through to another person, an organization credential, or a legacy token.
|
||||
Agent grants are company-scoped, have exactly one `subjectAgentId`, cannot be
|
||||
organization defaults, and are installed only for that agent.
|
||||
|
||||
The connection installation is the credential owner's consent boundary. A
|
||||
personal setup may target every agent or a selected set, and runtime resolution
|
||||
considers only an enabled, active connection installed for the current agent.
|
||||
Within that boundary, Paperclip treats the run's server-resolved
|
||||
`responsibleUserId` as its credential principal, including for automated work;
|
||||
agents cannot choose or spoof this field. The owner must still be an active
|
||||
non-viewer company member at each use. A standing delegation is needed only
|
||||
when a run genuinely has no responsible user.
|
||||
|
||||
## Credential lifecycle
|
||||
|
||||
The production, staging, and development GitHub Apps deliberately disable
|
||||
user-to-server token expiration. The resulting long-lived access token is
|
||||
checked with GitHub's `/user` endpoint every 30 days, together with installation
|
||||
and repository summary refresh. Routine continuity requires no browser visit.
|
||||
|
||||
If GitHub returns an expiring access token and rotating refresh token instead,
|
||||
Paperclip stores both encrypted and:
|
||||
|
||||
- refreshes at least one hour before access expiry;
|
||||
- forces a rotation at least every 30 days while the instance is active;
|
||||
- serializes refresh through the existing database refresh lease and compare-
|
||||
and-swap update;
|
||||
- atomically advances both secret values before clearing the lease;
|
||||
- retries one forced refresh after a provider `401`.
|
||||
|
||||
Only an unrecoverable provider invalidation marks a grant
|
||||
`needs_reauthorization`. Installation removal or suspension is reported as an
|
||||
installation-health failure, not as token expiry.
|
||||
|
||||
## Repository access
|
||||
|
||||
OAuth completion verifies `/user`, every page of `/user/installations`, and every
|
||||
page of each installation's accessible repositories. Setup remains incomplete
|
||||
until at least one installation and repository are available. Paperclip stores
|
||||
the authenticated username and a grant-scoped display snapshot containing only
|
||||
repository IDs, full names, installation IDs, and private-repository flags. GitHub stays authoritative:
|
||||
this snapshot never authorizes repository access.
|
||||
|
||||
The permissions page shows repositories across authorized accounts by default.
|
||||
Use the account filter and search to narrow the list. The list scrolls after
|
||||
about ten rows and marks known private repositories with a lock. Configure on
|
||||
GitHub opens the app account chooser so users can add or update organization
|
||||
access. Refresh access after changing the selection. Older snapshots omit the
|
||||
private flag until refreshed. If a legacy grant lacks its app chooser URL,
|
||||
**Load GitHub configuration** refreshes access and recovers the app slug from
|
||||
GitHub installation metadata. The page does not substitute a single-installation
|
||||
settings URL for the account chooser.
|
||||
|
||||
The permissions page shows the authenticated GitHub account and the complete
|
||||
accessible repository list. **Refresh access** reloads it from GitHub. Older
|
||||
grants and grants invalidated by newer installation lifecycle events prompt for a
|
||||
refresh instead of presenting a stale list. The page links to GitHub's
|
||||
installation management page. Selected repositories are recommended; all-
|
||||
repository access retains its warning.
|
||||
|
||||
Fresh local test-drives use production Paperclip Cloud. Instance enrollment
|
||||
and provider enablement are separate: enrollment alone does not enable GitHub
|
||||
OAuth. Production must advertise the `github.code` profile (see Cloud's
|
||||
`docs/github-connector-deploy-bootstrap.md`). If it is unavailable, setup
|
||||
preserves the sign-in intent and offers a retry instead of silently switching
|
||||
to a personal access token. A successful retry preserves the chosen audience.
|
||||
|
||||
## Webhooks
|
||||
|
||||
Paperclip Cloud verifies `X-Hub-Signature-256` against the exact bounded request
|
||||
body before parsing, deduplicates by `X-GitHub-Delivery`, and persists a minimal
|
||||
normalized event before returning `202`. Raw webhook payloads are discarded.
|
||||
When registering an active binding, Paperclip sends the current user token only
|
||||
inside the signed, payload-bound broker request so Cloud can verify access to
|
||||
that exact installation; Cloud neither logs nor persists that proof token.
|
||||
Deliveries fan out independently to every enrolled instance bound to the GitHub
|
||||
installation and are sealed to each instance's public key.
|
||||
|
||||
The instance polls with backoff, stores a company-scoped idempotency receipt,
|
||||
and acknowledges only successful applications. A merged pull request updates
|
||||
its matching external-object snapshot and immediately runs the existing merge-
|
||||
confirmation resolver. It wakes the assignee only when that interaction's
|
||||
continuation policy requests it; unrelated Paperclip issues are not closed.
|
||||
The periodic GitHub merge sweep remains the reconciliation fallback.
|
||||
|
||||
Installation lifecycle events refresh or invalidate installation summaries and
|
||||
remove obsolete Cloud bindings. Activity records contain event identifiers and
|
||||
outcomes but no webhook content. GitHub webhook content is never first-party
|
||||
telemetry.
|
||||
|
||||
## Run projection
|
||||
|
||||
The resolved token is leased at run start as an audited class-3 secret and is
|
||||
projected only into the child process:
|
||||
|
||||
- `GH_TOKEN`, `GITHUB_TOKEN`, and an internal credential-helper environment key;
|
||||
- `GIT_TERMINAL_PROMPT=0`;
|
||||
- process-scoped `GIT_CONFIG_COUNT/KEY_n/VALUE_n` entries that clear ambient
|
||||
helpers, install a `github.com`-only helper, and rewrite GitHub SSH remotes to
|
||||
HTTPS;
|
||||
- author and committer identity using
|
||||
`<numeric-id>+<login>@users.noreply.github.com`.
|
||||
|
||||
Tokens never appear in arguments, URLs, files, logs, events, or model context,
|
||||
and the projection never replaces `HOME`.
|
||||
|
||||
Cloud deployment and exact GitHub App registration settings live in
|
||||
`paperclip-cloud/docs/github-connector-deploy-bootstrap.md`.
|
||||
|
|
@ -60,8 +60,10 @@ Google makes Workspace MCP generally available.
|
|||
| Google People | `https://people.googleapis.com/mcp/v1` | Read contacts |
|
||||
| Google Workspace Search | `https://workspacemcp.googleapis.com/mcp/v1` | Search Workspace |
|
||||
|
||||
The setup flow asks for the capability first. It then offers the authentication
|
||||
methods available for that capability:
|
||||
The setup flow asks for the capability first. When the managed method is
|
||||
available, it uses Paperclip by default. A small **Use your own Google OAuth app**
|
||||
link reveals the custom client fields; **Use Paperclip instead** returns to the
|
||||
managed method. The available authentication methods are:
|
||||
|
||||
- **Connect with Paperclip** uses the Paperclip Cloud broker when that exact
|
||||
profile is returned for this enrolled instance by the signed
|
||||
|
|
@ -80,6 +82,14 @@ default organization grant, while still recording which signed-in Google
|
|||
principal completed consent so refresh and reconnect stay bound to that
|
||||
principal.
|
||||
|
||||
Catalog discovery and connection creation use the same signed, instance-specific
|
||||
profile availability. Local enrollment files and Cloud-delivered environment
|
||||
identities follow this same path; neither enables managed methods globally in
|
||||
the static app definitions. Saved connections remain recognizable for OAuth
|
||||
callback, refresh, and revoke, while the broker enforces current profile access.
|
||||
Switching capability or authentication methods preserves the selected credential
|
||||
owner when the new method supports that owner.
|
||||
|
||||
## Broker profiles
|
||||
|
||||
The Paperclip-managed method signs every broker request with one explicit
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
# iMessage Photon verification
|
||||
|
||||
Date: 2026-09-11. Branch: `codex/imessage-photon`.
|
||||
Base inspected: `1c4bcff2b`; updated through master `ab15aff39`.
|
||||
Initial implementation checked: `7ada38eb7ef5dff5441f23c02131798b11d57712`.
|
||||
**Status: experimental; Pro shared-DM live journeys verified below. Dedicated groups and the remaining release matrix are not yet qualified.**
|
||||
|
||||
[PR #13299](https://github.com/paperclipai/paperclip/pull/13299) carries the current
|
||||
CI and review results. The Photon migration is `0275_easy_dragon_man.sql`, regenerated after master added its own 0274 agent-chat migration. Greptile reviewed the implementation commit at 5/5 with no
|
||||
actionable comments. This record distinguishes local evidence from live proof.
|
||||
|
||||
## Environment and versions
|
||||
|
||||
- Fresh worktree: `imessage-photon`; separate worktree configuration, instance,
|
||||
database/storage home, and application port 3109.
|
||||
- Browser tests use a disposable local-trusted instance on port 3319 with a new
|
||||
database and storage home. They mock the provider/control-plane responses.
|
||||
- Database integration tests use disposable embedded PostgreSQL with real channel,
|
||||
identity, task, attachment, publication, and interaction services.
|
||||
- Advanced SDK 2.1.0; grpc-js 1.14.4; nice-grpc 2.1.17; nice-grpc-common 2.0.4;
|
||||
heif2jpeg 0.1.6. Local converter execution: macOS arm64.
|
||||
- The synthetic HEIC fixture is generated from a solid-color 16×16 image. It has
|
||||
no personal photo content and does not qualify real iPhone HEIC/Live Photos.
|
||||
- Production credentials, line tokens, phone numbers, and participant identifiers
|
||||
are absent from this record. Test numbers/IDs in fixtures are synthetic.
|
||||
|
||||
The primary-instance seed attempt encountered existing source schema drift
|
||||
(`tool_connections_transport_check` missing), so the isolated worktree uses a clean
|
||||
instance. The primary database was not modified. Several test starts also reached
|
||||
macOS's 32-segment System V shared-memory limit. Only unattached IPC from this task's
|
||||
exited browser-test databases was eligible for cleanup; running instances were not
|
||||
stopped or altered.
|
||||
|
||||
## Deterministic acceptance evidence
|
||||
|
||||
`server/src/__tests__/photon/photon.test.ts` exercises Basic Cloud authentication,
|
||||
token redaction, dedicated/shared/missing allocation, immutable line identity,
|
||||
Unicode multipart publication, receipt recovery, unknown sends and explicit retry,
|
||||
upload receipt reuse, quota classification, per-part authorization, contiguous
|
||||
checkpoint recovery, ignored event frames, cutoff history, lease loss, real local
|
||||
gRPC framing/authentication, scoped state, duplicate-title poll IDs, poll creation
|
||||
before a local crash, answer parsing, source ownership, image bounds, and actual
|
||||
synthetic HEIC conversion.
|
||||
|
||||
`server/src/__tests__/photon/channel.integration.test.ts` composes the real channel
|
||||
service with the Photon adapter and synthetic provider responses. It proves the
|
||||
fresh linked-message/task/agent-publication setup requirement, restored DM reply,
|
||||
echo filtering, identity reservation, explicit group enablement, authorized poll
|
||||
resolution, per-person answer drafts, rejection reasons, exactly one canonical
|
||||
continuation record, delayed HEIC retry after restart, attachment provenance,
|
||||
quoted context, task generations, stale controls, retained pending input through
|
||||
pause, group removal, and a native continuation proof for a second group person.
|
||||
The checkpoint takeover test verifies the database lease and checkpoint update
|
||||
share one transaction.
|
||||
|
||||
The native continuation test caught a JSON key-order mismatch after JSONB storage.
|
||||
Both the recorded answer digest and reconstructed proof now use the existing
|
||||
canonical hash. This is a native authorization composition test, not evidence of
|
||||
a live model turn through Photon.
|
||||
|
||||
The Photon/OpenAPI follow-up also verifies safe setup credential, quota, network,
|
||||
and invalid-response errors, the complete board-only inspection contract, group
|
||||
participant response fields, and the unchanged credential binding after rejected
|
||||
replacement. All 39 Photon/OpenAPI tests and the server build passed after the
|
||||
review fix separating provider outages from invalid setup input.
|
||||
|
||||
The Photon browser cases in `tests/e2e/chat-adapters-ui.spec.ts` cover catalog
|
||||
discovery, multiple-line selection, password input, keyboard selection, vaulted
|
||||
credential payload shape, setup completion, group enablement, light/dark themes,
|
||||
mobile navigation/layout, and pause/resume. The surrounding suite covers existing
|
||||
Slack, Discord, GitHub, Teams, and Telegram surfaces.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Photon targeted tests | 31 passed, including checkpoint takeover, Live Photo companion retention, and native continuation authorization. |
|
||||
| Token gates | Passed. All four gates clean. |
|
||||
| Workspace typecheck | Full `pnpm -r typecheck` passed before and after rebase. |
|
||||
| Full chat-adapters browser suite | 38 passed, including Photon light/dark/mobile coverage and existing providers. |
|
||||
| OpenAPI contract | 8 passed, including mounted-route completeness, board-only inspection, and token-free response schemas. |
|
||||
| Post-rebase channel/native checks | 96 passed across Photon, OpenAPI, explicit native continuation, and chat-control admission retry. |
|
||||
| Native session resume | 37 passed after building the required local fake-provider binary. |
|
||||
| UI Vitest project | 6,008 passed across 582 files after rebase. |
|
||||
| Shared catalog project | 727 passed, including exact catalog and branding coverage. |
|
||||
| Repository Vitest suite | The initial `pnpm test:run` overlapped edits/rebase and was stopped; it is not a final-commit pass. Fresh targeted and CI checks supersede it. The serialized route run found the missing Photon OpenAPI contract, which is fixed and passes its 8-case suite. Full gate status is recorded in the linked PR. |
|
||||
| Build | Full `pnpm build` passed before and after rebase. |
|
||||
| Generated forward migration | Generated through `pnpm db:generate`; `@paperclipai/db check:migrations` passed. Disposable database migrations exercised by integration tests. |
|
||||
| Native HEIF platform packages | macOS arm64 executed; other published platforms not executed. |
|
||||
|
||||
### Local test prerequisites
|
||||
|
||||
The standard `pnpm test:run` launcher isolates `PAPERCLIP_CONFIG`, `PAPERCLIP_HOME`,
|
||||
and temporary files. Direct heartbeat/continuation tests must use equivalent
|
||||
isolation; otherwise the worktree preview configuration suppresses execution.
|
||||
The actual runner-driver fixture also requires:
|
||||
|
||||
```sh
|
||||
cargo build --manifest-path packages/paperclip-runner/runner/Cargo.toml --bin fake-codex-app-server
|
||||
```
|
||||
|
||||
A run without that binary failed at provider startup; the complete 37-case native
|
||||
session-resume suite passed after building it. Catalog assertions were updated
|
||||
for the 42nd visible app, and the focused catalog/Browse/board-gallery tests pass.
|
||||
Some broad package runs encountered host embedded-Postgres startup limits during
|
||||
concurrent local development. These startup failures are not provider proof;
|
||||
inspect the linked PR for the current complete gate results.
|
||||
|
||||
## Pro shared-DM live qualification (2026-09-12)
|
||||
|
||||
The operator approved Pro-compatible shared DMs with groups disabled. The live
|
||||
test uses the isolated instance on port 3109, a Photon Pro project, its enrolled
|
||||
test participant, and the participant's actual iPhone. The test source was fully
|
||||
seeded through the worktree CLI; the primary instance remains untouched.
|
||||
|
||||
Observed with SDK 2.1.0 on implementation base `e556f7dbefd3ee738bde7830d69d5e30c4e96872`
|
||||
plus the shared-DM changes in this PR:
|
||||
|
||||
- Project inspection and vaulted setup succeeded against Photon Cloud's actual
|
||||
shared allocation. Shared credentials select the fixed shared gateway and a
|
||||
project-scoped identity, without inventing an owned phone number.
|
||||
- At 13:16 UTC, the participant sent a fresh iMessage from their iPhone. Photon
|
||||
delivered it through authenticated recovery. The project-filtered event feed
|
||||
jumped from an empty cursor to a non-adjacent sequence; the dedicated-only
|
||||
adjacency check initially stopped in Attention.
|
||||
- After the shared recovery fix and an isolated server restart, reconnect replayed
|
||||
the original message at 13:26 UTC. Paperclip discovered the exact sender but
|
||||
created no conversation/task while the identity was unlinked. The normal private
|
||||
confirmation flow then linked that identity to the isolated Board account.
|
||||
- At 13:27–13:28 UTC, the fresh linked request created a task, ran the native
|
||||
Codex runner, and delivered the requested response back to Apple Messages.
|
||||
- A native poll created at 13:29 UTC survived restart. Setup initially rejected
|
||||
interaction answers until the endpoint was active, deadlocking a clarifying
|
||||
question before final-reply qualification. Photon now permits those responses
|
||||
during its verified test step with the same identity, generation, and permission
|
||||
checks. After restart, a fresh vote at 13:33 UTC produced exactly one canonical
|
||||
answer and one native continuation. Its final reply arrived in Messages. A late
|
||||
unvote did not undo the answer. Setup then completed normally.
|
||||
- At 13:35–13:37 UTC, two free-text answers were collected sequentially. Early
|
||||
submission stayed pending; explicit submission of both drafts resumed the
|
||||
native agent with both exact values. The test also corrected the missing-answer
|
||||
hint to identify the unanswered question rather than always question 1.
|
||||
- At 13:38 UTC, the initial PNG/document import failed visibly because the shared
|
||||
gateway returns project attachment aliases in metadata and native UUIDs in
|
||||
stream headers. Shared downloads now retain authenticated source-message/chat
|
||||
checks and the exact alias-addressed RPC, validate the header metadata, and
|
||||
retain project aliases for provenance and restart. At 13:43–13:44 UTC, a fresh
|
||||
two-file message sent during the outage was recovered, imported, inspected by
|
||||
the agent, and returned as actual PNG and text-file attachments in Messages.
|
||||
The agent correctly identified the image and the document's verification word.
|
||||
- At 13:46–13:49 UTC, a canonical `request_confirmation` rejected a bare Reject
|
||||
reply with an actionable reason request. A correlated rejection with a reason
|
||||
resolved the canonical interaction and resumed the native agent, which returned
|
||||
the exact reason and confirmed that no further action ran.
|
||||
- At 13:49–13:50 UTC, a synthetic HEIC was sent through Apple Messages. Paperclip
|
||||
retained the 676-byte original and created a 633-byte JPEG derivative. The agent
|
||||
correctly described the solid blue 16×16 image and returned the original HEIC
|
||||
through Photon; the file appeared in Messages. This tests the real transport and
|
||||
converter together, but does not substitute for an actual iPhone camera photo.
|
||||
- At 13:51–13:53 UTC, Pause suppressed a delivered test message without creating
|
||||
a task. Resume did not replay it as work; a fresh request created the next task
|
||||
and received a reply. Reconnect reused the vaulted credentials and preserved
|
||||
project/allocation identity, then completed its fresh-message/reply test.
|
||||
- At 13:53–13:54 UTC, revoking the linked identity caused the next live message to
|
||||
be filtered with no task or agent run. The normal private confirmation flow
|
||||
restored the link. Completed tasks remained idle between fresh requests, and
|
||||
`/status` correctly reported no active task. `/new` requested a fresh message,
|
||||
and `/close` closed the next active conversation. Its late correlated answer
|
||||
left the old interaction unresolved and did not start another task.
|
||||
- At 13:56 UTC, Remove connection archived the test endpoint and its connection,
|
||||
cleared saved secret bindings, and stopped intake. A message sent while removed
|
||||
created no task. The same Photon project remained eligible in new setup.
|
||||
A replacement endpoint was linked normally and completed a fresh native
|
||||
task/reply test at 13:58 UTC. The test channel was left active.
|
||||
- At 18:13–18:14 UTC, an operator-supplied iPhone camera HEIC passed the same
|
||||
authorized Apple Messages conversation on code commit `fc4e4f0a3` (documentation
|
||||
head `a2a9319f3`). Messages transformed the 1,432,391-byte source into a
|
||||
1,132,602-byte HEIC before ingestion. Paperclip retained those received bytes
|
||||
and generated a 783,443-byte, 3024×4032 JPEG preview. The native agent correctly
|
||||
described the photo, then staged the HEIC with the same SHA-256 as the received
|
||||
original. Text and file publications each succeeded on their first attempt with
|
||||
provider receipts, and the returned photo appeared in Apple Messages. The native
|
||||
run succeeded and the task completed. The personal photo is not included in the
|
||||
repository or this report. This closes the real camera HEIC round-trip gap;
|
||||
Live Photo reassembly remains outside scope.
|
||||
- An identical published test send was repeated with its original key, exact
|
||||
payload digest, and reply target. Photon suppressed it but returned gRPC 6 with
|
||||
SDK `internalError` and an empty context, saying the operation was already
|
||||
processed. No new bubble appeared. Contrary to the documented original-result
|
||||
behavior, the shared gateway supplied no receipt. A regression test preserves
|
||||
delivery-unknown state in this case; no text matching or new key is used.
|
||||
- The shared receiver now commits only after the complete ordered replay barrier.
|
||||
Regression cases cover sparse events, interrupted/out-of-order replay, and cursor
|
||||
resets without advancing the saved checkpoint. Dedicated recovery remains strict.
|
||||
- All 39 chat-adapters browser tests passed, including shared setup after reload
|
||||
and existing provider coverage. The 20 Photon unit cases passed. An integration
|
||||
rerun initially hit the host's embedded-Postgres startup limit; this is a test
|
||||
environment failure, not a provider result.
|
||||
|
||||
The expanded unit suite has 22 passing cases, including shared attachment alias
|
||||
ownership, header validation, and missing duplicate receipts. After merging master
|
||||
and regenerating migration 0275, all 16 integration cases passed at code commit
|
||||
`fc4e4f0a32d35e41e56f6698404fca64cee3f32b`. Full workspace typecheck, build, token
|
||||
gates, and migration checks passed on that commit. The isolated instance then
|
||||
restarted successfully, reported startup ready on that commit, and retained the
|
||||
active shared-DM endpoint and linked identity. A broad `pnpm test:run` was started and stopped
|
||||
when the host's shared-memory limit prevented the live isolated PostgreSQL from
|
||||
restarting. Only this task's exited test database resources were removed. This
|
||||
interrupted run is not a full-suite pass; current CI must qualify the final commit.
|
||||
|
||||
All 30 applicable CI checks passed on `a2a9319f3`, with two skipped checks. One
|
||||
unchanged Cursor sandbox command-selection case initially exceeded its 10-second
|
||||
timeout. The exact case passed locally in 735 ms, and the failed CI server shard
|
||||
passed on its single rerun. Greptile rated that head 5/5 with no unresolved review
|
||||
threads. The 22 Photon unit cases also passed in Linux CI, including native HEIC
|
||||
conversion. Subsequent changes to this record add qualification evidence only;
|
||||
the linked PR shows their current check status.
|
||||
|
||||
Photon's CLI manages projects and users; its terminal provider simulates chat UI.
|
||||
Neither substitutes for actual Cloud iMessage delivery. The local Mac initially
|
||||
classified the assigned number as RCS, while the participant's iPhone sent the
|
||||
observed iMessage. No RCS/SMS fallback was enabled.
|
||||
|
||||
## Live qualification still required
|
||||
|
||||
Dedicated-line credentials were unavailable during the initial implementation.
|
||||
The Pro shared-DM journeys above passed; the remaining matrix must be completed
|
||||
before release readiness. Live inbound receipt alone is not full qualification.
|
||||
Record the tested commit, package versions, redacted project/line/chat IDs,
|
||||
participants, timestamps, and observable results when running it.
|
||||
|
||||
| Live case | Status |
|
||||
| --- | --- |
|
||||
| Linked DM creates task and receives actual agent response | Passed with Pro shared DMs and the native Codex runner. |
|
||||
| Enabled group with two linked people preserves attribution | Disabled for the approved Pro scope; dedicated-line live qualification remains unrun. |
|
||||
| Unlinked sender cannot start work | Passed for the live shared-DM probe; sender discovered, zero conversations/tasks created. |
|
||||
| Inbound/outbound photos and real iPhone HEIC | Passed for PNG, text file, synthetic HEIC, and an operator-supplied iPhone camera HEIC. The real photo produced a full-resolution JPEG preview and a byte-identical return of the received HEIC. |
|
||||
| Native poll and text answer resume correct interaction | Passed, including sequential drafts, incomplete submission, explicit submission, and one poll continuation. |
|
||||
| Approval rejection reason reaches canonical interaction | Passed, including missing-reason correction and native continuation. |
|
||||
| Restart preserves DM/group replies and pending questions | Shared DM recovery and pending native poll passed; dedicated groups remain unrun. |
|
||||
| Pause/resume/reconnect/removal enforce authority | Passed for Pro DMs. Removal archived the endpoint and connection, cleared secret bindings, and stopped intake. |
|
||||
| Completed turn stays idle until fresh input | Passed. September 12 correction: two successive real follow-ups reopened PHOTON-17, with no new task. |
|
||||
| Provider ambiguous-send/idempotency behavior | Real repeated key suppressed duplicates but returned no original receipt. Unknown-send recovery remains an operator action; no induced network-timeout test. |
|
||||
| HEIF conversion on Linux glibc/Windows and deployment packaging | macOS arm64 and Linux CI conversion passed. Windows execution remains unrun. Linux musl has no packaged converter. |
|
||||
|
||||
Keep this channel behind the existing experimental gate. Mocked tests, synthetic
|
||||
gRPC, and a visible catalog card do not establish these live results.
|
||||
|
||||
### September 12: persistent conversation and live task bubbles
|
||||
|
||||
The operator reported three messages creating PHOTON-15, PHOTON-16, and
|
||||
PHOTON-17. Task completion had incorrectly been treated as the end of an
|
||||
iMessage conversation, and channel admission did not emit the comment event
|
||||
used by an open task page. The fix preserves the latest task until an explicit
|
||||
`/new` or `/close`, publishes comment activity after its transaction commits,
|
||||
and labels inbound human bubbles in both task-chat renderers.
|
||||
|
||||
Tested the fix in the isolated `codex/imessage-photon` worktree on September 12,
|
||||
2026 at 13:56–13:57 America/Chicago, against the operator's existing Pro DM
|
||||
endpoint (`99bebf95…3884`) and PHOTON-17 (`bd6d8379…ba15`). Left the task page
|
||||
open and sent two authorized messages through Apple Messages to the same Photon
|
||||
conversation, waiting for completion between sends. Both appeared without a
|
||||
page reload and both reopened PHOTON-17. Its bubbles showed “Sent from
|
||||
iMessage”; the agent returned “PHOTON-17 live follow-up received” and
|
||||
“PHOTON-17 still one conversation” through Photon. The earlier task records
|
||||
were preserved as history. No live `/new` was sent to replace the operator's
|
||||
current conversation; explicit reset, close, stale controls, duplicate delivery,
|
||||
restart, and dedicated-group continuity are covered by integration fixtures.
|
||||
|
||||
The live server was then restarted on `4d7222110`. A third message asked the
|
||||
agent to repeat its previous reply. It appeared live on PHOTON-17 with its
|
||||
iMessage label, and the agent returned the exact previous reply through Photon.
|
||||
All 304 focused tests passed on that commit, and the existing Teams completion
|
||||
boundary passed its separate regression test.
|
||||
|
||||
Interactive Storybook coverage lives under **Connections / iMessage Photon**.
|
||||
It uses the production catalog card, three-step channel wizard, access and
|
||||
management pages, and task message bubbles with explicitly simulated provider
|
||||
actions. Thirteen stories cover catalog discovery, agent selection, credentials,
|
||||
shared setup, multiple dedicated lines, missing allocation, loading, connecting,
|
||||
outage recovery, reconnect, identity access, and persistent task follow-ups.
|
||||
All 26 light/dark Playwright cases passed, including the 390px mobile layout.
|
||||
The credential and mobile screenshots were inspected. Run with:
|
||||
|
||||
```sh
|
||||
pnpm build-storybook
|
||||
pnpm exec playwright test --config tests/storybook-visual/imessage-photon.config.ts
|
||||
```
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
# iMessage Photon
|
||||
|
||||
**Status: experimental. Live-provider qualification is pending.**
|
||||
|
||||
This channel connects one agent to Photon Cloud. Pro shared allocation supports
|
||||
DMs; dedicated lines also support explicitly enabled groups. A linked
|
||||
Paperclip person can send a DM, exchange files, answer questions, and respond to
|
||||
ordinary confirmations. Each conversation remains attached to a Paperclip task.
|
||||
The connection is a channel with `chat_sdk` transport, not an MCP tool connection.
|
||||
|
||||
## Prerequisites and setup
|
||||
|
||||
1. Enable the existing experimental chat-connectors setting. Open **Apps →
|
||||
iMessage Photon**, or the agent's **Channels** panel.
|
||||
2. In the [Photon dashboard](https://app.photon.codes/), obtain a project ID
|
||||
and project secret. Paperclip checks the project's actual allocation. Pro
|
||||
shared allocation is eligible for DMs only. Enroll each sender in the Photon
|
||||
project's **Users** page and find their assigned number in **Get started**.
|
||||
This enrollment does not authorize them in Paperclip. See Photon's
|
||||
[line model](https://photon.codes/docs/spectrum-ts/providers/imessage/connection-and-routing).
|
||||
3. Choose one invokable agent. Enter the project ID and secret, inspect the
|
||||
allocation. Connect shared DMs, or select a dedicated line. A single eligible dedicated line is selected
|
||||
automatically. A number already reserved by any non-archived endpoint in
|
||||
this instance cannot be selected, including a paused or revoked endpoint.
|
||||
Shared projects have the same exclusive reservation by project ID. Their
|
||||
assigned numbers may differ by sender and are not represented as owned numbers.
|
||||
4. Send a fresh message to the displayed dedicated number, or to the sender's
|
||||
assigned number from Photon for shared DMs. Link the discovered Messages
|
||||
identity through Paperclip's identity confirmation flow. Send another fresh
|
||||
message from that linked person. Setup completes only after a task is created
|
||||
and an actual agent response is published successfully.
|
||||
5. With a dedicated line, to use a group, add the number in Apple Messages and send a message to discover
|
||||
it. Enable the group in Paperclip's Settings page, then send a fresh request.
|
||||
Discovery does not enable a group or replay the discovery message as work.
|
||||
|
||||
The server needs outbound HTTPS to `spectrum.photon.codes` and TLS gRPC to the
|
||||
selected `<line-id>.imsg.photon.codes:443` endpoint, or
|
||||
`imessage.spectrum.photon.codes:443` for a shared project. No public webhook, Mac Messages
|
||||
permissions, Spectrum application runtime, or additional agent loop is needed.
|
||||
|
||||
Project secrets are write-only and vaulted. Inspection is restricted to connection
|
||||
managers and returns project identity, line IDs, phone numbers, and eligibility;
|
||||
it does not return credentials or line tokens. Agents never receive the project
|
||||
secret. The server holds short-lived line tokens in memory, renews before expiry,
|
||||
and checks that the project, line, and number have not changed. Every operation
|
||||
uses that selected line. Replacing credentials must preserve the same identity;
|
||||
connect a different identity with a new endpoint.
|
||||
|
||||
Setup and inspection distinguish credential/allocation errors (HTTP 422), quota
|
||||
limits (429), temporary provider outages (503), and invalid upstream responses
|
||||
(502). An outage does not mean valid credentials need replacement. A failed
|
||||
reconnect leaves the existing credential binding intact.
|
||||
|
||||
## Conversation and access rules
|
||||
|
||||
DMs are enabled by default. Dedicated groups start disabled; shared channels
|
||||
reject groups at admission, publication, and settings changes. Unlinked people cannot
|
||||
start work unless an operator explicitly enables that setting. Identity links
|
||||
use the provider-authenticated sender address and service. A phone number and an
|
||||
Apple-account email are separate identities; names and group membership do not
|
||||
grant Paperclip authority. Revoked links and inactive/viewer memberships cannot
|
||||
answer interactions. Guest work retains the shared channel restrictions.
|
||||
|
||||
Enabling a group makes the agent's responses visible to everyone in that group.
|
||||
It does not authorize every participant to start work. Every authorized message
|
||||
in an enabled group can start or continue work without a mention. Group names
|
||||
and participants are displayed in Settings. If the agent's number leaves the
|
||||
group, that destination becomes unavailable and publication is blocked.
|
||||
|
||||
DMs and groups are linear conversations. An authorized request starts a task;
|
||||
follow-ups append to the current generation through the ordered delivery queue.
|
||||
Completing a task ends the current turn. The next message reopens that same task,
|
||||
including after a server restart. Incoming messages appear live on the open task
|
||||
as user bubbles labeled “Sent from iMessage.” `/status` shows the current task,
|
||||
`/close` closes the conversation,
|
||||
and `/new` closes the current generation so the next request starts a new task.
|
||||
Quoted message GUIDs and multipart references are retained as task context.
|
||||
Quotes do not create separate tasks. A quoted control from an older generation
|
||||
cannot close a newer task. Outgoing echoes, reactions, read receipts, typing,
|
||||
and nonhuman system messages do not start agent work.
|
||||
|
||||
Messages, tasks, assets, publications, identities, and state remain company-scoped.
|
||||
Number and shared-project reservations are deliberately instance-wide. Task assignment, budget
|
||||
limits, pauses, approvals, and native/legacy execution continue through the
|
||||
existing Paperclip services.
|
||||
|
||||
## Questions and confirmations
|
||||
|
||||
Ordinary `ask_user_questions` uses native polls for closed single-choice questions
|
||||
with 2–10 options. Prompts include a text alternative. Correlation uses the returned
|
||||
poll message GUID and option IDs; duplicate titles and option labels are not lookup
|
||||
keys. Responses from other devices, added options, missing actors, expired prompts,
|
||||
and later vote changes cannot undo a completed decision.
|
||||
|
||||
Reply to the exact prompt, or use `/answer <reference>[.<question>] <value>`.
|
||||
Numbered choices, comma-separated multiple choices, custom text, and optional
|
||||
`skip` answers are supported. Questions appear sequentially. Multiple-question
|
||||
sets save a separate draft for each person and require `/submit <reference>`.
|
||||
Paperclip's canonical validators check required answers and selection/numerical
|
||||
rules before resolution. Different people cannot contribute to the same draft.
|
||||
|
||||
Ordinary `request_confirmation` offers explicit Accept/Reject. A required rejection
|
||||
reason is collected through a correlated text response. Target revision, audience,
|
||||
current identity, task generation, endpoint status, and permissions are rechecked
|
||||
at submission. Responses resolve through the canonical interaction service and
|
||||
its durable continuation delivery. A terminal acknowledgement is published once.
|
||||
Arbitrary “yes” messages and tapbacks never constitute approval.
|
||||
|
||||
Credential proposals, connection authorization, governed tool actions, and review
|
||||
kinds that need the full review surface remain in Paperclip. The channel supplies
|
||||
a task link and instructions. No individual-iMessage web permalinks are fabricated.
|
||||
|
||||
## Photos and files
|
||||
|
||||
Text, JPEG/PNG/WebP/GIF, allowed documents, audio, and video use Paperclip's existing
|
||||
attachment policy and byte limits. Provider upload allowances do not raise those
|
||||
limits. Attachments are source-bound to the selected line, chat, message, and
|
||||
attachment GUID before downloading. The server verifies that ownership again on
|
||||
recovery, bounds metadata, streamed bytes, time, and decoded image dimensions,
|
||||
and reports rejected/unavailable files in the task. A not-yet-ready attachment
|
||||
retries before waking the agent, without creating another comment.
|
||||
|
||||
HEIC/HEIF are included in the default attachment policy; operator overrides still
|
||||
win. The original remains downloadable and a JPEG derivative supplies browser
|
||||
preview and image input to the agent. The derivative records its source attachment
|
||||
and hashes. Conversion runs in a separate process with input/output/pixel limits
|
||||
and a deadline. `heif2jpeg@0.1.6` publishes macOS, Windows, and Linux glibc packages
|
||||
for x64/arm64; it does not publish Linux musl binaries. A missing or failed converter
|
||||
retains the original and reports preview unavailability. Only macOS arm64 has been
|
||||
executed locally for this change; other platform binaries still require qualification.
|
||||
|
||||
Live Photo stills and policy-allowed companion videos are retained as attachments
|
||||
on the same message. Native Live Photo reconstruction is not implemented. Outbound
|
||||
files require the existing task/company/agent/originating-run authorization. The
|
||||
server uploads actual bytes; it never sends private storage URLs to Photon.
|
||||
|
||||
## Publication and recovery
|
||||
|
||||
Only output classified for external publication is sent. Internal commentary,
|
||||
reasoning, raw tool output, and credentials stay internal. Final responses use
|
||||
normal bubbles and the channel refreshes typing while work runs. Text is split at
|
||||
paragraph boundaries with a 4,000-Unicode-code-point target and preserved order.
|
||||
Source-message reply references are used when the originating run identifies one.
|
||||
Every text part, attachment message, poll, and explicitly staged correction has a
|
||||
stable `clientMessageId` and immutable payload. Upload completion is recorded before
|
||||
the attachment message is sent. Native edits have a bounded window; ordinary final
|
||||
responses and acknowledgements are separate messages, never token-by-token edits.
|
||||
|
||||
A timeout after transmission is **delivery unknown**. Inspect the activity record
|
||||
and known Photon receipts, then use Paperclip's operator resolution/retry controls.
|
||||
Do not retry by creating another publication or changing its key. Explicit retries
|
||||
reuse the original key and payload. Similar text is not evidence of delivery. An
|
||||
ambiguous upload without a recorded receipt also needs operator review.
|
||||
|
||||
One elected receiver holds the endpoint lease. Live streams notify a serial
|
||||
catch-up reader. The reader advances its checkpoint only after preceding events
|
||||
are durably admitted or classified, including irrelevant events. It deduplicates
|
||||
provider sequence and message identity independently and reconstructs chats,
|
||||
attachments, and poll mappings from persisted state after restart.
|
||||
|
||||
Dedicated recovery requires adjacent sequence numbers. The shared gateway's
|
||||
project-filtered feed has increasing, non-adjacent sequences. Shared recovery
|
||||
commits its checkpoint only after the complete replay barrier and every preceding
|
||||
admission succeed. Interrupted or out-of-order replay retains the previous cursor.
|
||||
Shared channels do not subscribe to the unsupported group stream.
|
||||
|
||||
The pinned SDK's public catch-up iterator discards sequence-only/unknown-variant
|
||||
frames. Paperclip's small authenticated gRPC recovery transport retains their
|
||||
sequence while delegating known event decoding to the SDK. This prevents false
|
||||
history gaps without silently skipping a frame. A missing/reset cursor or an
|
||||
actual history gap stops in Attention. Initial historical messages establish a
|
||||
checkpoint but do not create old tasks automatically.
|
||||
|
||||
Pause stops execution and external publication while retaining already accepted
|
||||
pending work. Resume establishes a new intake cutoff, so messages deliberately
|
||||
suppressed during pause do not become work. Outage recovery catches up eligible
|
||||
missed messages. Disconnect archives the endpoint, stops streams, invalidates
|
||||
interaction authority, and removes owned secret bindings. It does not delete the
|
||||
Photon project, number, subscription, or Messages history. Hiding experimental
|
||||
UI alone does not disconnect existing channels.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| State or symptom | Action |
|
||||
| --- | --- |
|
||||
| Invalid project credentials | Replace the vaulted secret for the same project/number and reconnect. |
|
||||
| Shared allocation | Connect shared DMs, enroll the sender in Photon, and use their assigned number. Groups require a dedicated line. |
|
||||
| No eligible dedicated lines | Review the project's line allocation in Photon, then inspect again. |
|
||||
| Number already owned | Use its existing endpoint or remove that endpoint before reconnecting the number. Pause retains the reservation. |
|
||||
| Number changes/disappears | Review the Photon allocation. Restore the original identity or create a new endpoint. |
|
||||
| No task from a group message | Groups are disabled for shared channels. For a dedicated channel, enable the discovered group, link the sender, and send a fresh request. |
|
||||
| Setup remains Verifying | Complete the linked fresh-message → task → actual agent reply loop; a credential check is insufficient. |
|
||||
| Quota/network interruption | Review Activity. Transient errors retry with bounded backoff; quotas are distinct from authentication failures. |
|
||||
| Attachment preparing | Let the durable delivery retry; do not resend the message to force another task. |
|
||||
| Preview unavailable | Download the original and verify converter support/policy on this deployment platform. |
|
||||
| Delivery unknown | Reconcile the exact provider receipt or explicitly retry the same immutable publication. |
|
||||
| Missing/reset cursor or history gap | Review the affected period before operator recovery. The service does not silently skip it. |
|
||||
| Old poll no longer works | Open the task's current interaction. Completed/expired polls cannot reverse a decision. |
|
||||
|
||||
Diagnostics use existing local activity and run records. This change adds no
|
||||
first-party Telemetry events. Persisted receipts/checkpoints are required for
|
||||
recovery; do not manually delete provider state to resolve an outage.
|
||||
|
||||
## Qualification and source versions
|
||||
|
||||
See [implementation and acceptance plan](../plans/2026-09-11-imessage-photon.md)
|
||||
and [verification record](IMESSAGE-PHOTON-VERIFICATION.md). Deterministic fixtures
|
||||
and synthetic gRPC are not live-provider proof. A dedicated test line, known
|
||||
participants, real iPhone HEIC, and native polls are required before claiming the
|
||||
full live acceptance loop.
|
||||
|
||||
Pinned dependencies: `@photon-ai/advanced-imessage@2.1.0`, `@grpc/grpc-js@1.14.4`,
|
||||
`nice-grpc@2.1.17`, `nice-grpc-common@2.0.4`, `heif2jpeg@0.1.6`.
|
||||
|
||||
First-party references inspected on 2026-09-11:
|
||||
[Cloud authentication](https://github.com/photon-hq/spectrum-ts/blob/main/packages/core/src/utils/cloud.ts),
|
||||
[SDK](https://github.com/photon-hq/advanced-imessage-ts),
|
||||
[events](https://photon.codes/docs/advanced-kits/imessage/events),
|
||||
[polls](https://photon.codes/docs/advanced-kits/imessage/polls),
|
||||
[attachments](https://photon.codes/docs/advanced-kits/imessage/attachments),
|
||||
[idempotency](https://photon.codes/docs/advanced-kits/imessage/error-handling), and
|
||||
[HEIF converter](https://photon.codes/docs/utilities/heif2jpeg).
|
||||
|
||||
### Shared-gateway duplicate receipts
|
||||
|
||||
The Pro shared gateway has been observed returning gRPC `ALREADY_EXISTS` as SDK
|
||||
`internalError`, without a receipt, when an identical `clientMessageId` is repeated.
|
||||
Paperclip retains delivery-unknown state if no stored receipt exists. Inspect the
|
||||
original conversation and use the existing operator resolution action. Do not
|
||||
create another idempotency key or infer delivery from matching text. Photon’s
|
||||
[documented idempotency behavior](https://photon.codes/docs/advanced-kits/imessage/error-handling)
|
||||
says repeated writes return the original result; the live shared-gateway result
|
||||
is recorded separately in the verification report.
|
||||
|
|
@ -84,9 +84,9 @@ values for either.
|
|||
6. For OAuth, continue through browser consent. For API-key setup, create a
|
||||
personal API key using PostHog's **MCP Server** preset and paste it into
|
||||
Paperclip. Never put the key in connection configuration or a URL.
|
||||
7. Review discovered actions. Known writes and destructive actions default to
|
||||
**Ask first**, and unknown PostHog tools default to write risk so they inherit
|
||||
that approval gate unless the operator changes the selection.
|
||||
7. Review discovered actions. Every discovered action starts **Allowed**,
|
||||
including writes and destructive actions. Unknown PostHog tools are still
|
||||
classified as write risk so operators can identify and narrow them when needed.
|
||||
|
||||
When configured, Paperclip sends the optional project pin as the
|
||||
`x-posthog-project-id` managed header. Without it, PostHog keeps an active
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ agent tutorial from provider research and protocol classification through
|
|||
manifest generation, branding, secrets, deterministic tests, real-account
|
||||
proof, and PR submission.
|
||||
|
||||
Runtime authentication: [AI Connections](./AI-CONNECTIONS.md).
|
||||
|
||||
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
|
||||
[Gmail](./GMAIL.md), [PostHog](./POSTHOG.md). Optional credential custody:
|
||||
[Gmail](./GMAIL.md), [PostHog](./POSTHOG.md),
|
||||
[AgentMail](./AGENTMAIL.md), and [iMessage Photon](./IMESSAGE-PHOTON.md). Optional credential custody:
|
||||
[Vercel Connect](./VERCEL-CONNECT.md).
|
||||
|
||||
Post-read action: classify a new integration request, pick the right Paperclip
|
||||
|
|
|
|||
|
|
@ -44,8 +44,10 @@ context and server-side ownership checks.
|
|||
Slack needs channel/workspace bounds, Google Drive/Docs needs drive/folder/doc
|
||||
bounds, and equivalent broad providers need provider-specific bounds before
|
||||
agent grants are usable.
|
||||
6. **Write/admin actions are explicit opt-ins.** Read access does not imply write
|
||||
access. Destructive or newly changed write actions default to review.
|
||||
6. **Write/admin actions stay explicit and visible.** Completing connection
|
||||
setup is the operator's opt-in to the selected active catalog. New
|
||||
connections default those active actions to Allowed; newly discovered or
|
||||
changed write actions still enter quarantine for review.
|
||||
7. **Revocation is immediate and failure-closed.** Revoked secrets, disabled
|
||||
connections, expired policies, missing secret refs, or failed health checks
|
||||
block new execution and queued mutation work.
|
||||
|
|
@ -307,5 +309,6 @@ Redaction and agent safety:
|
|||
board-supervised rollout.
|
||||
- Provider OAuth/app-installation scopes may be broader than Paperclip resource
|
||||
filters. Paperclip must enforce the narrower internal filter.
|
||||
- High-risk writes still need good UX. Default them to ask-first, dry-run, or
|
||||
draft semantics until product copy and review flows are proven.
|
||||
- High-risk writes still need good UX. Prefer provider-side dry-run or draft
|
||||
semantics, clear action names, and narrow explicit provider policy where an
|
||||
Allowed new-connection default would be unsafe.
|
||||
|
|
|
|||
|
|
@ -87,10 +87,9 @@ the URL bar, e.g. `PAP`). Replace it in the example paths.
|
|||
anything.
|
||||
|
||||
> **Which fixture am I in?** The Connections list shows both, and the stdio one
|
||||
> may be listed first. If you open a fixture's **Setup** tab and there is no
|
||||
> **Connect with Smoke OAuth** card — only the "Agents can use this app" toggle —
|
||||
> you're in the **stdio** fixture. Go back and open **Smoke Lab HTTP MCP
|
||||
> fixture** for the OAuth steps.
|
||||
> may be listed first. Open **Permissions** and check the action names: the HTTP
|
||||
> fixture includes **List synthetic todos**, while the stdio fixture includes
|
||||
> **Deterministic time**. Use **Smoke Lab HTTP MCP fixture** for the OAuth steps.
|
||||
|
||||
> If **Start services** errors with a `403`, re-check §0 — you're on a `public`
|
||||
> (internet-facing) instance. Any private instance works, including the everyday
|
||||
|
|
@ -101,8 +100,8 @@ the URL bar, e.g. `PAP`). Replace it in the example paths.
|
|||
## 3. The lifecycle you'll exercise on every path
|
||||
|
||||
Each path P1–P7 walks the same governed lifecycle. You drive it from a fixture
|
||||
connection's pages — a small left-hand menu inside the app with **Setup**,
|
||||
**Review**, **Permissions**, **Activity**, **Test**, and **Advanced**
|
||||
connection's pages — a small left-hand menu inside the app with
|
||||
**Permissions** and **Review** (plus **Services** for broker connections)
|
||||
(`/{PREFIX}/apps/{connectionId}/{tab}`).
|
||||
|
||||
Two things to know before you start:
|
||||
|
|
@ -110,23 +109,23 @@ Two things to know before you start:
|
|||
- **Actions are listed by their display title**, with the raw tool name behind
|
||||
them — e.g. `todo.list` renders as **List synthetic todos**. The table below
|
||||
gives both.
|
||||
- **"Policies" are the per-action dropdowns on the Permissions tab.** Each action
|
||||
is **Off**, **Allowed**, or **Ask a human first**. When a step below says "with
|
||||
a require-approval policy in force", that means: set that action's dropdown to
|
||||
**Ask a human first**. "Block policy" means set it to **Off**. Fresh installs
|
||||
start conservative, so check the dropdown before running a step.
|
||||
- **"Policies" are the three-way per-action toggles on the Permissions tab.**
|
||||
Each action is **Off**, **Ask first**, or **Allowed**. When a step below says
|
||||
"with a require-approval policy in force", set that action to **Ask first**.
|
||||
"Block policy" means set it to **Off**. New connections start Allowed; narrow
|
||||
an action before testing when the scenario requires another decision.
|
||||
|
||||
| Step | What you do | What you should see |
|
||||
|---|---|---|
|
||||
| **connect** | Open the fixture connection (for P1, complete the fake OAuth consent). | Connection shows **Connected**, with the action count. |
|
||||
| **discover-catalog** | Open **Permissions**. | The action list includes the path's tools (e.g. **List synthetic todos**). |
|
||||
| **allowed-read** | Set the read action to **Allowed**, then run it from the **Test** tab. | Decision badge **Allowed**; the call returns without error. |
|
||||
| **ask-first-write** | Set the write action to **Ask a human first**, then run it from **Test**. | Decision **Ask first**; a pending request appears in **Review**. |
|
||||
| **allowed-read** | Set the read action to **Allowed**, then use its **Test** button on **Permissions**. | Decision badge **Allowed**; the call returns without error. |
|
||||
| **ask-first-write** | Set the write action to **Ask first**, then use its **Test** button. | Decision **Ask first**; a pending request appears in **Review**. |
|
||||
| **approve** | **Review** tab → approve the pending write. | The request clears; the call completes. |
|
||||
| **denied-call** | Set the blocked action to **Off**, then run it from **Test**. | Decision **Off**; the call is refused with a reason. |
|
||||
| **denied-call** | Set the blocked action to **Off**, then use its **Test** button on **Permissions**. | Decision **Off**; the call is refused with a reason. |
|
||||
| **schema-change / quarantine** | Trigger the fixture schema flip (HTTP paths), then **Refresh actions** on Permissions. | A **quarantine** pill with the changed entries held back. |
|
||||
| **revoke** | **Setup** → turn off the **"Agents can use this app"** toggle (or revoke the gateway session for P6). | The connection is paused; a revoked token is cut off (401). |
|
||||
| **audit-evidence** | **Activity** tab. | Audit rows for the allowed, approved, denied, quarantine, and revoke decisions. |
|
||||
| **revoke** | From **Connectors**, choose **Remove connection** from the connection's management menu. In the classic table, use the trash button labeled **Delete _app_ connection**. (For P6, revoke the gateway session instead.) | Agent access is removed immediately; a revoked token is cut off (401). |
|
||||
| **audit-evidence** | Open company **Audit** and choose **Apps & tools** in the Action filter. | Audit rows for the allowed, approved, denied, quarantine, and revoke decisions. |
|
||||
|
||||
(The results matrix in §6 folds **approve** into its *Ask-first write* column, so
|
||||
the matrix shows 8 columns for these 9 steps.)
|
||||
|
|
@ -146,45 +145,48 @@ This is the richest path — do it by hand once and the rest are variations.
|
|||
|
||||
1. **Connect via the fake OAuth provider.**
|
||||
- From **Apps → Connections** (`/{PREFIX}/apps`), open **Smoke Lab HTTP MCP
|
||||
fixture** (not the stdio one — see the callout in §2), then choose **Setup**.
|
||||
- **You should see:** a **Connect with Smoke OAuth** card ("Open the provider's
|
||||
consent page to finish connecting this app.") with a **Connect with Smoke
|
||||
OAuth** button. If someone already connected it, the card reads **Connected
|
||||
with Smoke OAuth** with a **Reconnect** button instead — Reconnect walks the
|
||||
same flow.
|
||||
fixture** (not the stdio one — see the callout in §2). If its header says
|
||||
**Needs attention**, use the **Reconnect** action directly below the header.
|
||||
- **You should see:** the reconnect card explains that the saved connection
|
||||
needs authorization and offers **Connect with Smoke OAuth**. If the fixture
|
||||
is already healthy, no reconnect card is shown.
|
||||
- Click it. The fake provider's **real consent page** opens: a brown banner
|
||||
*"SMOKE TEST - not a real provider"*, headed *"Paperclip Smoke OAuth login +
|
||||
consent"*.
|
||||
- The **email is pre-filled** (`smoke@paperclip.test`). Type the password
|
||||
`smoke-password` and click **Authorize smoke test app**.
|
||||
- **You should see:** the provider accepts the credentials and returns you to
|
||||
this connection's **Setup** tab with the card now reading **Connected with
|
||||
Smoke OAuth**. Wrong credentials are rejected with a `403`.
|
||||
this connection's **Permissions** page with a **Connected** status. Wrong
|
||||
credentials are rejected with a `403`.
|
||||
2. **Discover the catalog.** Open **Permissions** and confirm **List synthetic
|
||||
todos** (`todo.list`) and **Add synthetic todo** (`todo.add`) appear under
|
||||
*Action permissions*.
|
||||
*Actions*.
|
||||
3. **Allowed read.** Make sure **List synthetic todos** is set to **Allowed** in
|
||||
Permissions. Then on the **Test** tab, pick an agent in the **Test as** picker
|
||||
and run **List synthetic todos**. **You should see:** an **Allowed** badge and
|
||||
a result with no error.
|
||||
Permissions. Click its **Test** button, pick an agent in the **Act as** picker,
|
||||
and run it. **You should see:** an **Allowed** badge and a result with no error.
|
||||
4. **Ask-first write → approve.** In Permissions, set **Add synthetic todo** to
|
||||
**Ask a human first**. Run it from the **Test** tab. **You should see:** an
|
||||
**Ask first**. Click its **Test** button and run it. **You should see:** an
|
||||
**Ask first** badge and a **pending** request. Switch to the **Review** tab
|
||||
(its idle state says "Nothing is waiting for your OK right now") and
|
||||
**approve** it. **You should see:** the request clears and the write completes.
|
||||
5. **Denied call.** In Permissions, set **Send outbox email** (`email.send`) to
|
||||
**Off**, then run it from **Test**. **You should see:** an **Off** badge and a
|
||||
refusal carrying a reason code.
|
||||
**Off**, then click its **Test** button. **You should see:** an **Off** badge
|
||||
and a refusal carrying a reason code.
|
||||
6. **Schema change → quarantine.** Run **Fixture schema mutation**
|
||||
(`fixture.schemaFlip`) — it changes a tool's schema — then click **Refresh
|
||||
actions** on the **Permissions** tab. **You should see:** a **quarantine**
|
||||
pill (on Review and Permissions) — the changed entries are held back until you
|
||||
explicitly turn them on.
|
||||
7. **Revoke.** On **Setup**, turn off the **"Agents can use this app"** toggle.
|
||||
**You should see:** the app is paused for every agent. (Turn it back on to
|
||||
continue.)
|
||||
8. **Audit evidence.** **Activity** tab. **You should see:** rows for each decision
|
||||
above (allowed, approved, denied, quarantine, revoke).
|
||||
7. **Revoke.** Return to **Apps → Connections** and choose **Remove connection**
|
||||
from the connection's management menu. In the classic table, use the trash
|
||||
button labeled **Delete _app_ connection**. **You should see:** a confirmation
|
||||
explaining that saved credentials are deleted and agent access ends
|
||||
immediately. Reinstall the fixture apps before continuing with another path
|
||||
that uses this connection.
|
||||
8. **Audit evidence.** Open company **Audit** and choose **Apps & tools** in the
|
||||
Action filter.
|
||||
**You should see:** rows for each decision above (allowed, approved, denied,
|
||||
quarantine, revoke).
|
||||
|
||||
> Prefer not to click all seven by hand? Use the automated browser smoke — §7 —
|
||||
> which performs exactly these steps and leaves you screenshots to read, including
|
||||
|
|
@ -204,23 +206,23 @@ tools change.
|
|||
- **P3 — Local stdio MCP template.** Uses the **Smoke Lab stdio MCP fixture**
|
||||
connection and its tools (see the stdio row in §3's table). The read is
|
||||
**Deterministic time** (`time.now`); the "denied" tool **Crashing stdio
|
||||
fixture** (`crash.now`) is blocked by policy. Its **Setup** tab has no OAuth
|
||||
card — just the "Agents can use this app" toggle. Quarantine evidence is
|
||||
recorded via fixture metadata rather than an HTTP schema flip.
|
||||
fixture** (`crash.now`) is blocked by policy. It does not require OAuth.
|
||||
Quarantine evidence is recorded via fixture metadata rather than an HTTP
|
||||
schema flip.
|
||||
- **P4 — Plugin-provided integration.** Exercises the catalog-backed **app install**
|
||||
path a plugin would use, over the stdio fixture. Same stdio tools as P3.
|
||||
**You should see:** Activity rows record the install + lifecycle decisions.
|
||||
**You should see:** Audit rows record the install + lifecycle decisions.
|
||||
- **P5 — Paste-a-config / run-your-own import.** Entry via the **Developer**
|
||||
section of Apps; import the HTTP fixture through the advanced configuration
|
||||
surface, then run the same HTTP lifecycle. **You should see:** advanced
|
||||
Activity rows show the import and the governed calls.
|
||||
Audit rows show the import and the governed calls.
|
||||
- **P6 — Token broker / gateway session.** Create a **run-scoped gateway session**
|
||||
for the smoke agent, list tools through the session token, then **revoke** the
|
||||
session. **You should see:** the token lists tools before revoke and is **cut
|
||||
off (401)** after. Entry/evidence via **Activity**.
|
||||
off (401)** after. Entry/evidence via **Audit**.
|
||||
- **P7 — Governance surfaces.** Entry via **Review**. This path is about the
|
||||
governance surfaces themselves — profiles, ask-first policies, block policies,
|
||||
and quarantine. **You should see:** Review and Activity expose the ask-first,
|
||||
and quarantine. **You should see:** Review and Audit expose the ask-first,
|
||||
block, quarantine, and revoke evidence together.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
# Connection review verification — 2026-09-08
|
||||
|
||||
Implementation workspace: `/Users/dotta/paperclipai/branches/codex/reviews-in-task`.
|
||||
Branch: `codex/reviews-in-task`, rebased on `master` at `8f099c3f8`.
|
||||
The original verification below predates that rebase; final checks are recorded in the PR.
|
||||
|
||||
## Acceptance status
|
||||
|
||||
The deterministic integration paths demonstrate both synchronization directions
|
||||
and scripted-agent continuation. A real native Codex approval and continuation
|
||||
also passed in the local test drive, as recorded below. Live Notion and the
|
||||
remaining real model-runner journeys are **untested dependencies**. The PR records
|
||||
the final repository and CI check results.
|
||||
|
||||
## Inspect the UI
|
||||
|
||||
Storybook is running from this worktree on port 6018:
|
||||
|
||||
- [Interactive task](http://localhost:6018/?path=/story/chat-comments-connection-reviews--interactive-task)
|
||||
- [All card states](http://localhost:6018/?path=/story/chat-comments-connection-reviews--all-states)
|
||||
- [Connections queue](http://localhost:6018/?path=/story/chat-comments-connection-reviews--connections-queue)
|
||||
- [Narrow layout](http://localhost:6018/?path=/story/chat-comments-connection-reviews--narrow)
|
||||
|
||||
Manual browser inspection covered light/dark presentation, the split approval
|
||||
menu, keyboard approval, and dismissal/reopening. The final card shows only the app
|
||||
icon, request description, and decision controls. Optional labels and details were
|
||||
removed. Narrow controls fit without horizontal clipping.
|
||||
The fixture queue can be resolved interactively to inspect its empty state.
|
||||
|
||||
## Deterministic browser journeys
|
||||
|
||||
Each journey creates a company, agent, and custom MCP connection in an isolated
|
||||
embedded database. Ask first is configured through the permissions UI. The provider
|
||||
returns fixture page names; **these are not real Notion pages**.
|
||||
|
||||
| Journey | Observed provider calls | Verified outcome |
|
||||
| --- | ---: | --- |
|
||||
| Approve in task | 1 | Stored call executes; resumed task posts Roadmap/Meeting notes; Connections pending item clears |
|
||||
| Decline in Connections | 0 | One-click decline; open task updates; resumed agent reports decline |
|
||||
| Always allow | 2 | Initial approved call and a later call with changed arguments; later task has no review |
|
||||
| Provider failure | 1 | Human approval remains recorded; task shows execution failure and resumed agent reports it |
|
||||
| Restart while waiting | 1 | Pending request survives actual server restart; approval executes once and task returns page results |
|
||||
|
||||
All journeys also exercise takeover dismissal/reopening, an ordinary comment while
|
||||
pending, reload, and cross-tab synchronization. Review creation performs zero
|
||||
provider calls. Traces and screenshots accompany each case.
|
||||
|
||||
[Open the evidence gallery](http://127.0.0.1:6020/) or the
|
||||
[Playwright report](http://127.0.0.1:6020/report/). The final run passed all five
|
||||
journeys in 1.4 minutes and released its port after teardown.
|
||||
|
||||
The local evidence directory is `.paperclip-runtime/reviews-evidence/`. It contains
|
||||
the Playwright report, traces/screenshots, focused/full-check logs, baseline logs,
|
||||
and `final-journey-identifiers.json` with request, invocation, interaction, and run IDs
|
||||
from the passing port-3226 run. The report's attachments also contain
|
||||
company/task/agent IDs and provider counts for its own run.
|
||||
|
||||
## Automated checks
|
||||
|
||||
- 384 focused gateway, policy/service, native bridge, and card/queue tests pass.
|
||||
- 177 additional interaction route/service and policy tests pass.
|
||||
- 18 startup tests pass after updating their app mocks with recovery services.
|
||||
- 19 runner-catalog tests pass; the opt-in suite defines 16 local cells.
|
||||
- Added scope/repair regressions pass: another agent/project still asks, explicit
|
||||
denial and changed definitions remain effective, concurrent approval executes
|
||||
once, multiple outcomes share one durable wake, and interrupted execution is
|
||||
never replayed.
|
||||
- Repository `pnpm -r typecheck` and `pnpm build` pass.
|
||||
- Runner harness TypeScript, token gates, migration safety, and Storybook build pass.
|
||||
- `pnpm test:run` was run, but the repository-wide result is not green. Feature
|
||||
failures found in the initial run were corrected and their suites rerun above.
|
||||
Thirteen unrelated failures were reproduced at the same unchanged master commit:
|
||||
two workspace-runtime tests, four workspace-repair/control tests, three runtime
|
||||
exposure tests, two company-skill path tests, one instance-cleanup path test,
|
||||
and one worktree-seed spawn test.
|
||||
The initial general-server lane ended with 5,968 passed, 39 failed, and 31
|
||||
skipped tests across 498 files. Twenty failures were feature changes corrected
|
||||
and verified in focused reruns; six were transient file-resource/runtime-port
|
||||
failures that passed on rerun. The thirteen remaining failures reproduce on
|
||||
master. The fail-fast runner did not reach later workspace/serialized lanes.
|
||||
A complete green repository run is still required before PR-ready handoff.
|
||||
|
||||
## Live provider dependencies
|
||||
|
||||
The normal runner command was attempted with the opt-in flag and stopped with
|
||||
`Missing runner E2E credentials: OPENAI_API_KEY, ANTHROPIC_API_KEY`.
|
||||
|
||||
The normal `test-drive --harness codex` command was also attempted from this
|
||||
worktree. It bootstrapped a fresh isolated instance and reached startup recovery on
|
||||
127.0.0.1:3105, then shut down with `No credential found. Set OPENAI_API_KEY`.
|
||||
No Notion OAuth connection or real Notion page read was performed. The native Codex approval journey subsequently passed using existing local
|
||||
ChatGPT authentication, as recorded below. Native ACPX Claude, legacy Codex CLI,
|
||||
and legacy Claude CLI journeys remain unverified. The scripted process-adapter/browser evidence must not substitute for
|
||||
those 16 acceptance cells or the four-profile real Notion exercise.
|
||||
|
||||
Provide the normal runner/test-drive credential setup and Notion account access
|
||||
to complete those journeys. Secrets should remain in the normal local environment
|
||||
or credential store, not in this report or chat.
|
||||
|
||||
## Final simplified UI verification
|
||||
|
||||
Before the master rebase, 155 component tests and all five browser journeys passed.
|
||||
The final UI checks include the split approval menu, keyboard selection of Always
|
||||
allow, and one-click decline. Evidence is in `.paperclip-runtime/reviews-evidence/minimal/`.
|
||||
The browser run took 2.7 minutes; its restarted server required explicit process
|
||||
cleanup after the tests completed. Live provider and model-runner dependencies
|
||||
remain separate from this deterministic evidence.
|
||||
|
||||
## Native Codex approval and continuation
|
||||
|
||||
A real native Paperclip Runner agent used `gpt-5.6-sol` with existing local
|
||||
ChatGPT authentication. Its initial run discovered the installed MCP fixture
|
||||
action, called it with `query: "10 most recent pages"`, and yielded to a pending
|
||||
server-owned review. The operator approved in the browser. The server executed
|
||||
the stored request and delivered its result to a new native run.
|
||||
|
||||
- Source run: `106278a4-5411-41ba-b2a4-c150cdf7760d`.
|
||||
- Action request: `7da846da-496f-4e6a-a151-0252e10f989c`.
|
||||
- Continuation run: `18e11fe1-bd6a-4034-996e-e635d6cd57a6`.
|
||||
- Final task status: `done`.
|
||||
- Agent response: “The most recent fixture pages are **Roadmap**, **Meeting notes**,
|
||||
and **Product research**.”
|
||||
|
||||
The success card keeps the raw tool result collapsed. Expanding it displays
|
||||
formatted JSON. The latest five-journey browser suite passed in 1.7 minutes and
|
||||
checks separate source/reply run IDs, readable output, and result expansion.
|
||||
This is real Codex execution against a local fixture, not live Notion evidence.
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
# Connection reviews in task history
|
||||
|
||||
An agent call governed by **Ask human / Ask first** creates a server-owned
|
||||
`request_confirmation.payload.toolAction` interaction linked to the existing
|
||||
`tool_action_requests` record. No provider call runs while this review is pending.
|
||||
The default approval lifetime remains one hour.
|
||||
|
||||
The task feed keeps one record for each review. **Review request** opens the
|
||||
composer takeover. Dismissing the takeover only hides it; the review remains
|
||||
pending, ordinary comments do not supersede it, and the agent is not resumed.
|
||||
Multiple reviews retain separate records and the existing takeover navigation.
|
||||
The card shows the app icon and a short request description. Destructive actions
|
||||
retain destructive approval styling. Audit metadata and signed arguments remain
|
||||
on the underlying review record.
|
||||
|
||||
**Approve & run** executes the signed, stored arguments once. **Decline** executes
|
||||
nothing in one click. Both the task and Connections queue use
|
||||
one decision transaction; a decision removes the pending queue item while its
|
||||
history remains on the task. The human decision, resolver, remembered permission,
|
||||
and execution outcome remain separate. Provider failure does not turn an approved
|
||||
decision into a decline. Successful results stay collapsed behind the status chevron;
|
||||
expanding it shows formatted JSON (or plain text). The resumed agent processes the
|
||||
recorded result in a new turn and writes the user-facing answer. Live activity
|
||||
invalidation refreshes both surfaces, with existing polling/reconnect reconciliation
|
||||
retained.
|
||||
|
||||
## Remembered permission
|
||||
|
||||
Choose **Always allow** from the split button beside **Approve & run**. It
|
||||
atomically saves approval and an action-wide trust rule for the
|
||||
same agent, connection, and action, restricted to the originating project when one
|
||||
exists. Future argument values may differ. The menu item exposes the scope through
|
||||
its tooltip and accessible description; the receipt records the saved permission. If saving the rule
|
||||
fails, the approval transaction rolls back and no provider call runs.
|
||||
|
||||
The rule remains bound to the reviewed catalog definition/schema. Changed
|
||||
definitions require review again. Revocation, explicit denial, connection access,
|
||||
and formal approval requirements remain effective. Manage/revoke rules through the
|
||||
existing Connections trust-rule controls.
|
||||
|
||||
The accept/approve endpoints support optional `rememberAction: true`; omission
|
||||
continues to approve once. Trust-rule promotion supports `argumentMode: "action"`;
|
||||
its existing omitted/`"exact"` mode continues to bind exact argument values.
|
||||
|
||||
## Governed waiting and recovery
|
||||
|
||||
The gateway returns `approval_required` with the linked request/interaction IDs
|
||||
and instructions to finish unrelated work, then yield `in_review` without retrying
|
||||
or claiming completion. Agent task completion is rejected while a linked action
|
||||
is pending, approved, or executing. Provider execution is server-owned.
|
||||
|
||||
`tool_action_deliveries` is a durable, content-free outbox keyed by action request.
|
||||
It refers to the authoritative request, invocation, and interaction instead of
|
||||
copying provider data. Once the originating runs have ended and no other task
|
||||
interactions remain pending, ready outcomes are batched into one continuation
|
||||
wake. The wake includes the recorded result/decline and instructions not to repeat
|
||||
the operation. Native runners materialize validated server-owned interaction
|
||||
outcomes; legacy runners receive the wake context and agent message. Existing
|
||||
scheduler eligibility and budget gates still apply. Closed tasks retire receipts;
|
||||
reassignment does not deliver the old agent's outcome to another agent.
|
||||
|
||||
Startup and periodic sweeps recover committed approvals, undelivered outcomes,
|
||||
expiry, and incomplete feed projections. An execution left in progress for ten
|
||||
minutes is marked failed with `tool_execution_outcome_unknown`. Its external
|
||||
outcome is uncertain: inspect the provider before retrying. It is never
|
||||
automatically replayed. This grace period exceeds the current approved-call timeout.
|
||||
|
||||
Migration 0249 adds the outbox and a partial unique wake-idempotency index.
|
||||
The index is built transactionally; migration can briefly block wake-table writes
|
||||
while PostgreSQL scans an existing large table. No external payload is added to the
|
||||
outbox.
|
||||
|
||||
## Verification workflows
|
||||
|
||||
Run the credential-free, isolated browser suite:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_E2E_PORT=3222 pnpm exec playwright test -c tests/e2e/connection-reviews.config.ts
|
||||
```
|
||||
|
||||
This starts a dedicated embedded database/server and local MCP fixture, configures
|
||||
Ask first through the UI, and verifies approve, decline, remembered permission with
|
||||
changed arguments, dismissal/reopening, ordinary comments, cross-tab queue/task
|
||||
updates, provider failure, and restart while waiting. Assertions include useful
|
||||
agent results and provider invocation counts. Screenshots, JSON journey identifiers,
|
||||
and traces are attached to the Playwright HTML report. The deterministic agent is a
|
||||
scripted process adapter; these results do not prove model-runner behavior.
|
||||
|
||||
Run the opt-in, local model-runner matrix with the harness's normal credentials:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS=1 pnpm test:e2e:runner -- --suite connection-reviews
|
||||
```
|
||||
|
||||
The 16 cells cover approve, decline, always allow, and restart/resume for native
|
||||
Codex, native ACPX Claude, legacy Codex CLI, and legacy Claude CLI. Qualified model
|
||||
settings come from the existing runner catalog. This flag does not expand the
|
||||
normal hosted/Daytona matrix. The fixture MCP provider is real HTTP but is not
|
||||
Notion; live Notion evidence must be reported separately.
|
||||
|
||||
For a real Notion test, start `paperclipai test-drive` from this checkout using
|
||||
valid provider credentials, verify its process checkout/port ownership, connect
|
||||
Notion normally, set an available read-only search/list action to Ask human, and
|
||||
perform the same approve/decline/always-allow journeys for all four profiles.
|
||||
Capture request/run IDs, screenshots, traces, and actual page results. Missing
|
||||
credentials or account/provider access are untested dependencies, never a pass.
|
||||
|
||||
## Storybook
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/ui exec storybook dev -p 6018 -c storybook/.storybook --no-open --ci
|
||||
```
|
||||
|
||||
Open **Chat & Comments / Connection Reviews**. The production task thread/card and
|
||||
Connections queue cover pending, dismissed/reopened, multiple requests, each
|
||||
submitting action, recoverable errors, concurrent resolution, approved/executing,
|
||||
success/failure, decline with/without a reason, expiry/cancellation, remembered
|
||||
scope/receipt, approval options, narrow layout, and queue/empty states. The global
|
||||
theme toolbar switches light/dark. Story actions simulate server responses; use the
|
||||
browser suite for integration proof.
|
||||
|
||||
Provider output, execution errors, and review notes travel in the continuation's
|
||||
`untrustedToolResults` field, separate from its control instructions. Both native
|
||||
and legacy wake prompts render those fields as fenced JSON with an explicit
|
||||
untrusted-data boundary. Embedded provider instructions cannot grant permission
|
||||
or change the task's continuation policy. Wake materialization redacts secrets
|
||||
and bounds each text field before rendering.
|
||||
|
||||
A continuation includes at most eight shortened result records and caps the
|
||||
serialized wake context at 32 KB. It links to the task interaction API for all
|
||||
full outcomes and instructs the agent to retrieve omitted or incomplete results
|
||||
before finishing. A committed receipt cutoff preserves acknowledgement of that
|
||||
referenced set across restart, without putting an unbounded ID list in the wake.
|
||||
Task review queries reconcile every 20 seconds if a live event is missed.
|
||||
|
|
@ -149,7 +149,7 @@ Before enabling another app method, run the real-provider smoke matrix:
|
|||
- create/attach the connector and validate its UID;
|
||||
- discover the MCP catalog;
|
||||
- run an allowed read;
|
||||
- confirm a write stops at ask-first and runs only after approval;
|
||||
- set a write to ask-first, then confirm it stops for approval and runs only after approval;
|
||||
- revoke in Vercel and confirm the one retry fails closed;
|
||||
- confirm the grant becomes `needs_reauthorization` and the audit trail contains
|
||||
no bearer, claims, bootstrap authority, or upstream response body;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ Run scope: `ui/src/components/` and `ui/src/pages/` on branch `design/token-extr
|
|||
|
||||
## Counts
|
||||
|
||||
Execution recovery reuses the existing transcript header and task status. Routine phases add no list badges, status cards, or reconciliation dialogs. Only a transient reconnection changes the header text. Automatic recovery decisions remain in the local run log. Storybook **Tasks / Execution recovery** demonstrates quiet task lists, native and legacy transcript headers, and dashboard composition.
|
||||
|
||||
| Area | Count |
|
||||
|---|---:|
|
||||
| Shared primitives (`ui/src/components/ui/`) | 24 |
|
||||
|
|
@ -128,7 +130,6 @@ Grouped by rough domain area. One line each; variants column is props-based wher
|
|||
| `ExternalObjectStatusIcon.tsx` / `ExternalObjectStatusSummary.tsx` / `ExternalObjectPill.tsx` | External-object (linked PR/doc/etc.) status glyph, rollup summary, and inline pill — a third, deliberately separate status-presentation family |
|
||||
| `BlockedReasonChip.tsx` | Chip explaining why a task is blocked |
|
||||
| `SourceTrustBadge.tsx` / `SourceResolvedFoldBadge.tsx` / `SourceResolvedFoldCallout.tsx` | Trust/fold badges for external content sources |
|
||||
| `ProductivityReviewBadge.tsx` | Review-status badge |
|
||||
|
||||
**KNOWN-DUPLICATES.md lead verified:** StatusIcon / inline-mention chips / task chips are intentionally three separate systems (StatusIcon+StatusGlyph = task status glyph family; `ExternalObjectStatusIcon`/`Pill`/`Summary` = a second, external-object-specific family; mention chips in `lib/mention-chips.ts` + markdown CSS = a third, generic "chip in prose" family). **Documented here per instruction, not merged.**
|
||||
|
||||
|
|
@ -404,3 +405,16 @@ Per-component rationale:
|
|||
### 7.3 Interactive-card affordance (Run 3 review feedback)
|
||||
|
||||
`Card` gained an `interactive` prop — pointer cursor, quiet hover (border→foreground/20 + shadow-md lift), focus-visible ring — used when the whole card is a click target (e.g. Companies selector). Skills tiles (CompanySkills `SkillCard`) and artifact cards (`ArtifactCard`/`ArtifactGroupCard`) apply the same recipe verbatim since they cannot render through Card (button/Link semantics). Static container Cards stay affordance-free by design.
|
||||
|
||||
|
||||
## In-task connections — 2026-09-07
|
||||
|
||||
| Reusable surface | Production owner | Hosts / coverage |
|
||||
|---|---|---|
|
||||
| Connection request card | `ui/src/features/connections/ConnectionIntentInteractionBody.tsx` | Task timeline, interaction card, design guide; pending, reuse, authorizing, retry, resolved, audience and error stories |
|
||||
| Connection setup flow | `ui/src/features/connections/ConnectionSetupFlow.tsx` | Connections page and task dialog share provider forms, OAuth, validation and additive installs |
|
||||
| OAuth handoff | `OAuthConnectStateScreen` in the shared setup module | Entry, starting, open window, blocked popup, closure, callback failure, retry and new-tab fallback |
|
||||
| Identity and agent access | `AccessStep` in the shared setup module | Personal, organization, dedicated agent, unavailable identity and loading; task host fixes install reach to the requester |
|
||||
| Setup completion | `ConnectionSetupCompletionScreen` in the shared setup module | Page and dialog; identity, granted agent access and enabled actions |
|
||||
|
||||
Independently addressable examples live under `Connections/In-task connections` in Storybook. The task composer remains available while a card is pending. These components use the existing token and primitive layers.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
# GitHub identity during agent execution
|
||||
|
||||
Shared agents use the GitHub connection of the person whose accepted instructions they are executing. Task ownership remains unchanged. GitHub is optional: ordinary work can start without a connection; a private checkout, authenticated API call, or commit can fail when that operation needs credentials or author metadata.
|
||||
|
||||
## Accepted instructions and continuations
|
||||
|
||||
`run_identity_contexts` records ordered revisions, stored message authors, originating causes, parent contexts, acceptance state, and redacted GitHub outcomes. `heartbeat_runs.active_identity_context_id` selects the current revision. Existing historical runs are not backfilled with inferred authorship.
|
||||
|
||||
Human messages use their stored authenticated author. Queued messages retain their delivery order. Accepting steering reserves a pending revision before delivery, then activates it after the provider acknowledgement. Rejected delivery leaves the prior revision active. An uncertain acknowledgement holds new credential acquisition; a later acknowledgement or its authenticated native event receipt reconciles the reservation. Replays cannot reactivate an older revision. Activation locks the task before the run, matching task and queue mutations so concurrent status changes cannot deadlock identity initialization.
|
||||
|
||||
Delegated work and interactions persist their originating context. Retries retain the originating run's active context. Background continuations carry their source run; dependency wakes use the task's continuation context, independently of its owner. Scheduled and webhook routines use the routine's responsible person; manual invocations use the caller, and edits preserve the routine's responsible person.
|
||||
|
||||
## Managed GitHub operations
|
||||
|
||||
Executions with managed GitHub configured receive token-free `git` and `gh` launchers and a run-scoped capability. Each launcher invocation requests the active context through the authenticated runtime transport and resolves one eligible credential at operation start. A `gh` command's child Git processes inherit that command's captured identity. Later steering does not change already-started operations. When a subsequent run resumes a settled native conversation, the controller starts a fresh provider process with that run’s capability and rebinds its token-free launcher paths. The durable conversation and protected provider settings remain unchanged. Local and remote durable runners complete their bounded suspension before the controller releases the session for the next run, so a queued continuation cannot race unfinished cleanup.
|
||||
|
||||
The broker endpoint rejects browser origins and session cookies, validates a distinct signed runtime scope, and rechecks the company, agent, and live run. Sandboxes relay the capability through the existing authenticated callback bridge. Tokens are returned only to the managed command process. They are not persisted in identity history or injected into the long-lived provider process.
|
||||
|
||||
Low-trust executions cannot receive raw GitHub credentials, including dedicated
|
||||
agent tokens. The broker rechecks current agent, project, task, and retained run
|
||||
policies before credential resolution. An external guest's internal sponsor is
|
||||
accountable for the task, but does not authorize using the sponsor's account.
|
||||
Read-only access must use separately authorized tools that enforce that boundary.
|
||||
|
||||
Server-side Git operations and GitHub gateway calls follow the same selection rules. Approved gateway operations retain their signed originating identity. Connection audience and tool policies continue to apply to the selected person's connection. Native catalogs remain stable across identity changes, but each invocation resolves the selected grant again. Personal OAuth secret declarations survive connection pauses and metadata edits.
|
||||
|
||||
Managed commands disable ambient Git credential helpers, Git global/system configuration, host GitHub CLI configuration, and host SSH identity access. Per-operation GitHub CLI configuration is isolated in a writable configuration directory beneath the managed launcher directory. Missing credentials clear previous author and token values; no teammate, standing delegation, host token, or company-default user's account is substituted. Anonymous/local operations remain available where supported.
|
||||
|
||||
Remote launchers prepend their directory to the execution target's effective
|
||||
`PATH`. An explicit remote `PATH` override is preserved; otherwise Paperclip
|
||||
reads the provider's environment before staging the launcher shell files.
|
||||
This keeps legacy NVM and user-local agent installations available alongside
|
||||
newer images with system-wide CLIs. The generated shell files retain that
|
||||
combined path with managed `git` and `gh` first. Sandbox command checks use
|
||||
the same sanitized environment as execution, so a CLI visible only in the
|
||||
provider's default environment cannot pass the launch check. Failed path
|
||||
discovery stops startup instead of silently falling back to a minimal path.
|
||||
|
||||
Scripts that previously read a persistent `GH_TOKEN` must use managed `git`, `gh`, or GitHub gateway tools. Managed execution skips legacy GitHub token bindings in agent, environment, project, and routine configuration before secret preflight. Configure personal or dedicated access through the GitHub connection instead. Directly invoking an unmanaged executable or retaining a token obtained during an earlier invocation is outside the managed invocation contract.
|
||||
|
||||
## Legacy hosts and networking
|
||||
|
||||
When no managed GitHub connection is installed for an agent, standard-trust
|
||||
local and SSH executions retain that execution host's existing Git and GitHub
|
||||
CLI credentials, configuration, credential helpers, and SSH agent. Paperclip
|
||||
does not import controller credentials into an SSH target. Sandbox, plugin,
|
||||
and low-trust executions do not receive this compatibility fallback. Once a
|
||||
managed connection is configured, unavailable or revoked access never falls
|
||||
back to host authentication. Switching modes replaces the provider process
|
||||
while preserving the settled conversation.
|
||||
|
||||
Runner network access is independent of GitHub credentials. The controller
|
||||
enables networking for standard-trust execution. Low-trust runs and runners
|
||||
without a controller network decision retain a restricted default. An operator
|
||||
can set `PAPERCLIP_RUNNER_NETWORK_ACCESS=disabled` to restrict normal execution;
|
||||
user environment bindings cannot override that decision. Outer execution-
|
||||
environment network restrictions still apply. The controller projects the assigned worktree's Git metadata paths so
|
||||
Git can operate without exposing unrelated workspace or provider state. The
|
||||
sandbox also receives read access to validated provider executable resources
|
||||
and the target host's DNS and CA files, including resolver symlink targets
|
||||
outside `/etc`. Provider credential directories remain isolated.
|
||||
|
||||
A managed broker outage does not prevent local Git operations. Launchers clear
|
||||
credentials and run the command without authentication, with a redacted error
|
||||
category identifying configuration setup, transport, or capability rejection.
|
||||
They do not retain a previous operation's token or replay a GitHub operation.
|
||||
|
||||
Healthy eligible grants for the same stable GitHub account ID take precedence
|
||||
over duplicates with failed health checks. Credential acquisition can retry
|
||||
once against another grant for that same principal and account, before any
|
||||
GitHub operation begins. Run identity diagnostics include the selected
|
||||
connection and grant IDs, without credential values. Access-refresh conflicts
|
||||
retry once against current state and never turn a concurrency conflict into
|
||||
a reconnect requirement.
|
||||
|
||||
## Dedicated accounts and diagnostics
|
||||
|
||||
An explicit dedicated-agent grant overrides personal selection. Revoked, disabled, unavailable, or ambiguous dedicated grants do not fall back to a person's account. Removing the dedicated configuration restores personal selection.
|
||||
|
||||
Connection setup and permissions display: “This agent uses this GitHub account for everyone's work, instead of the person giving instructions.”
|
||||
|
||||
The GitHub permissions page shows repositories across all connected accounts in one scrollable list. It has no account filter or repository search. Repository icons, private-repository indicators, refresh, and GitHub configuration links remain available. The “Add More Repos on GitHub” button opens GitHub’s app installation and repository-access setup.
|
||||
|
||||
Multiple eligible connections for the same GitHub account are treated as one
|
||||
identity, using GitHub's stable account ID rather than its login. The resolver
|
||||
selects an available grant, preferring the newest authorization with a stable
|
||||
ID tie-breaker. Duplicate eligibility includes an active credential record with
|
||||
the correct owner, the OAuth access-token reference, and repository access
|
||||
metadata. It keeps that grant's credential and connection policy together;
|
||||
it does not combine repository access or bypass connection audiences. Distinct
|
||||
accounts or unidentifiable duplicate grants remain ambiguous. Managed commands
|
||||
print the redacted reason when GitHub access is unavailable, while unrelated
|
||||
local operations can still proceed without credentials.
|
||||
|
||||
Run details show identity revisions and redacted GitHub results: responsible person, selected login when available, personal/dedicated source, and an unavailable reason. Tasks do not receive an additional identity indicator or takeover action.
|
||||
|
||||
## Deployment and verification
|
||||
|
||||
Deploy the schema, server broker, launcher staging, and runtime environment contract together. Already-running processes retain their original environment; only newly dispatched processes receive the broker contract. Run-scoped capabilities remain valid only while their bound run is active.
|
||||
|
||||
Focused coverage lives in `run-identity.test.ts`, `github-operation-credentials.test.ts`, and `github-launcher.test.ts`, alongside the native steering, gateway, routine, and callback-bridge suites. Live acceptance additionally requires two authenticated Paperclip users, two authorized GitHub accounts, and a designated disposable repository for push verification. Local commit metadata and mocked API results do not replace that live push test.
|
||||
|
||||
### Release procedure
|
||||
|
||||
1. Back up the instance database using the normal deployment procedure.
|
||||
2. Build and deploy one revision containing migrations 0240–0245, the server broker, managed launchers, and the runner artifacts. Run the standard pending-migration check before admitting new runs. These additive migrations are safe to replay and do not infer authorship for historical runs.
|
||||
3. Let pre-rollout executions finish with their original runtime contract. New executions must have an active identity context and the managed launcher capability before provider startup.
|
||||
4. Check one ordinary run without a GitHub connection, then an authenticated GitHub operation. Inspect the run details for the responsible person and credential outcome. Verify a queued continuation on the same conversation.
|
||||
5. If rollback is needed, finish or explicitly stop executions using the new broker before removing its endpoint. Keep the additive schema and identity history. Do not drop identity columns or tables to roll back application code.
|
||||
|
||||
Remote acceptance uses the existing paid runner workflow with a narrow selection. Run it against the same immutable revision as the release; a successful local test does not qualify a different remote runner artifact.
|
||||
|
||||
Identity history survives deletion of the originating agent or run, so surviving
|
||||
subtasks and approvals retain their responsible person. The company foreign key and company-deletion service remove
|
||||
these company-scoped records when their company is deleted. Completed runs remove their managed launcher files
|
||||
before releasing a remote environment; same-run recovery retains them until the
|
||||
terminal boundary. Cleanup failures are logged and do not change the run result.
|
||||
|
|
@ -144,6 +144,26 @@ The active-lock lifecycle is part of the checkout contract:
|
|||
|
||||
Stale-lock recovery is crash recovery, not a retry loop. Paperclip must not clear or adopt locks held by non-terminal runs. After stale cleanup, a checkout `409` should mean a real live owner, status/assignee mismatch, unresolved blocker, or active gate still prevents checkout. Agents must treat that `409` as an ownership conflict and stop rather than retrying the same checkout.
|
||||
|
||||
### Known execution waits at admission
|
||||
|
||||
A known execution hold is a waiting condition, not a new execution attempt. Every issue wake must read the current effective reconciliation hold under the issue admission lock before creating a run. Resolved recovery bookkeeping can still carry a no-replay hold; only clearing the effective hold makes admission eligible again. The final dispatch gate remains required for changes after admission.
|
||||
|
||||
Repeated automatic signals for an unchanged gate share one durable skipped-wake diagnostic, scoped to company, agent, issue, gate code, and condition identity. The diagnostic retains the first request and counts later observations. This applies to execution reconciliation, dependencies, pause holds, company and agent availability, budget blocks, and disabled heartbeats. These diagnostics do not consume provider attempts and are never proof that a future wake was delivered. All current gates are checked again on the next wake, including the periodic dependency reconciliation sweep. Clearing one gate does not bypass another.
|
||||
|
||||
New comments received during an execution hold retain their individual deferred receipts and ordered comment ids. Release cannot drain those receipts while replay remains blocked; the next eligible wake can adopt them. Authorized external-chat requests also remain deferred with their exact durable receipt. They must use normal promotion and current authorization; a generic wake cannot adopt only their comment ids and discard their actor or session contract. A wait does not authorize replay, reset an incident retry budget, or bypass an interaction's delivery rules.
|
||||
|
||||
The conversation groups repeated empty pre-start reconciliation cancellations into a neutral waiting notice. Started runs, actual startup failures, and run history remain inspectable. No historical run records are deleted.
|
||||
|
||||
Workspace contention (`workspace_busy`) displays **Waiting for workspace** and
|
||||
continues automatically when the workspace is available. Internal scheduling
|
||||
attempts remain in the run log without conversation cancellation markers,
|
||||
cancellation toasts, or manual Retry controls. Users can keep sending instructions.
|
||||
|
||||
The legacy remote ACP process-session relay runs on the control-plane host. Its
|
||||
launch command uses the host's absolute Node executable even when the adapter's
|
||||
launch environment is sanitized for a remote sandbox; the sandbox PATH remains
|
||||
owned by the sandbox image.
|
||||
|
||||
### Pre-dispatch configuration validation
|
||||
|
||||
Pre-dispatch configuration validation is a distinct gate that runs after ownership and checkout are resolved but before the control plane actually dispatches a run.
|
||||
|
|
@ -342,6 +362,8 @@ A board comment can be an interrupt, an ownership change, both, or neither. Pape
|
|||
|
||||
An interrupt stops the current live execution path for the issue. It does not, by itself, select the next owner. If an active run is interrupted by the board, the run may still terminate with the underlying `cancelled` status, but the issue activity and wake context should make the operator intent visible as an interruption rather than an unexplained runtime failure.
|
||||
|
||||
For legacy runners, **Interrupt** on a queued message stops the active run and explicitly continues the pending queue after execution cleanup. It validates the queue revision and target run, then dispatches the requested queue’s current message bodies in their saved order. Other actors’ queues cannot consume that interrupt. The persisted interrupt intent is retried by the scheduler after a promotion error or server restart until that queue is dispatched or discarded. Edits and discards remain authoritative until dispatch; deleting the final message must not create an empty continuation. Pending messages remain visible after a run stops. Cancelling only the run preserves the queue for a later explicit wake; pausing the task retains its separate queue-cancellation behavior. Native same-turn steering keeps its separate acknowledgement protocol. Legacy Codex uses Ctrl-C to stop its tool sessions and cannot retry a missing-session fallback after the provider has confirmed that the session started.
|
||||
|
||||
An ownership change selects who owns the issue after the comment is committed:
|
||||
|
||||
- setting `assigneeAgentId` makes the named agent the owner
|
||||
|
|
@ -388,6 +410,8 @@ The handshake failure code is distinct from a session-identity mismatch. A timeo
|
|||
|
||||
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
|
||||
|
||||
The task thread exposes the existing guarded Retry action for failed or timed-out legacy conversation runs. Where the server supports an explicit new attempt after a stopped legacy conversation, the thread must not hide that action solely because the old run still has a recovery-needed projection. Native and process recovery holds, pending decisions, active execution, and other retry gates remain in force. When a gate hides Retry, the thread says the message is preserved instead of promising an unavailable action. This presentation change does not rewrite historical outcomes or certify prior actions.
|
||||
|
||||
A valid recovery action must name:
|
||||
|
||||
- the source issue and company
|
||||
|
|
@ -471,7 +495,7 @@ Agent-assigned `in_review` with no typed participant is only healthy when one of
|
|||
|
||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery action. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
||||
|
||||
When an execution-policy review stage has a pending agent participant, the participant's run is part of the review path only while it is live or queued. If that participant run reaches a terminal state while `executionState.status` remains `pending`, no decision has been recorded. Paperclip should queue one bounded normal-model recovery wake for the same participant when the agent is invokable and no other review path exists. If that recovery run also finishes while the stage remains pending, or the participant cannot be invoked, Paperclip must move the source issue to an explicit blocked/recovery path instead of leaving `in_review` to drift silently.
|
||||
When an execution-policy review stage has a pending agent participant, the participant's run is part of the review path only while it is live or queued. If that participant run reaches a terminal state while `executionState.status` remains `pending`, no decision has been recorded. After a successful run with no review decision, Paperclip should queue one bounded normal-model recovery wake for the same participant when the agent is invokable and no other review path exists. A failed participant instead follows the provider-continuity rules below: local conversational adapters can start a bounded continuation turn, while native sessions use validated resume/replacement. Other adapters retain their action-recovery gates. The original assignee stays unchanged. If that recovery run also finishes while the stage remains pending, or the participant cannot be invoked, Paperclip must move the source issue to an explicit blocked/recovery path instead of leaving `in_review` to drift silently.
|
||||
|
||||
### Issue monitors
|
||||
|
||||
|
|
@ -489,6 +513,12 @@ Monitor policy lives under `executionPolicy.monitor` and includes:
|
|||
|
||||
Monitors are not recurring intervals. When a monitor fires, Paperclip clears the scheduled monitor and queues an `issue_monitor_due` wake for the assignee. If the external service is still pending, the assignee must explicitly re-arm the monitor with a new `nextCheckAt`. If the issue moves to `done`, `cancelled`, an invalid status, or a human/unassigned owner, the monitor is cleared.
|
||||
|
||||
The task's waiting banner and composer countdown also display automatic retries
|
||||
while their run is `scheduled_retry`. Once a retry is `queued` or `running`, its
|
||||
retained `scheduledRetryAt` is historical and must not produce a waiting or overdue
|
||||
warning. A separately scheduled monitor remains visible. Completed and cancelled
|
||||
tasks hide both waiting surfaces even if a stale schedule remains in the response.
|
||||
|
||||
Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials.
|
||||
|
||||
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible issue-backed recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
|
||||
|
|
@ -552,6 +582,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
|
||||
|
|
@ -583,15 +615,16 @@ Automatic retries that can continue source work use the agent's configured model
|
|||
|
||||
Startup recovery and periodic recovery are different from normal wakeup delivery.
|
||||
|
||||
On startup and on the periodic recovery loop, Paperclip now does five things in sequence:
|
||||
On startup and on the periodic recovery loop, Paperclip performs the following recovery passes:
|
||||
|
||||
1. reap orphaned `running` runs
|
||||
2. resume persisted `queued` runs
|
||||
3. reconcile stranded assigned work
|
||||
4. scan silent active runs only for source-aware terminal folding and legacy cleanup; API reads classify ordinary output silence for the board UI
|
||||
5. reconcile productivity reviews
|
||||
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output.
|
||||
|
||||
Automatic productivity reviews are retired. Run counts, missing comments, and elapsed task time do not create review tasks or impose continuation holds. Bounded continuation, provider recovery, budget limits, explicit blockers, and normal review/approval stages remain in force. Existing productivity-review tasks, comments, assignments, and dependencies remain unchanged and readable; their historical origins still identify them as recovery work for recursion suppression.
|
||||
|
||||
### Issue-thread interaction resolution
|
||||
|
||||
|
|
@ -765,7 +798,7 @@ Do not fold a run only because it is quiet. Keep the informational signal visibl
|
|||
|
||||
In the normal non-terminal case, critical silence remains a UI signal and does not block the source issue. In the source-resolved case, a completed source issue does not acquire a new review or blocker merely because an old run handle stayed active. Only real unresolved work should block work.
|
||||
|
||||
This is distinct from productivity review. Productivity review asks whether an assigned source issue has unusual progression patterns, such as no-comment terminal-run streaks, long active duration, or high churn. Source-resolved watchdog folding asks whether a stale active-run signal outlived a source issue that already reached a valid terminal disposition. One does not substitute for the other.
|
||||
Source-resolved watchdog folding concerns stale active-run bookkeeping after a valid terminal disposition. It does not infer productivity from run counts, comment frequency, or elapsed task time.
|
||||
|
||||
Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains.
|
||||
|
||||
|
|
@ -779,12 +812,169 @@ Auto-recovery is allowed when ownership is clear and the control plane only lost
|
|||
|
||||
Examples:
|
||||
|
||||
- requeue one dispatch wake for an assigned `todo` issue whose latest run failed, timed out, or was cancelled
|
||||
- requeue one continuation wake for an assigned `in_progress` issue whose live execution path disappeared
|
||||
- requeue one dispatch wake for an assigned `todo` issue whose latest run failed, timed out, or was cancelled under the bounded conversation or provider-continuity rules below
|
||||
- requeue one continuation wake for an assigned `in_progress` issue whose live execution path disappeared under the bounded conversation or provider-continuity rules below
|
||||
- assign an orphan blocker back to its creator when that blocker is already preventing other work
|
||||
|
||||
Auto-recovery preserves the existing owner. It does not choose a replacement agent.
|
||||
|
||||
### Completion tools and final answers
|
||||
|
||||
A completion tool such as `paperclip_finish` reports task disposition; it does not
|
||||
end the provider turn. Paperclip continues persisting and displaying provider
|
||||
events until an authoritative turn terminal arrives. The completion report starts
|
||||
no interruption timer. Existing execution timeouts, cancellation, governed waits,
|
||||
and active-goal rules still apply. A later failed or cancelled terminal remains
|
||||
failed or cancelled even when the agent already reported completed work.
|
||||
|
||||
The final assistant message is the visible task response. Response selection runs
|
||||
after preceding event persistence completes; the completion summary cannot replace
|
||||
an available final answer. Existing fallback and explicit-comment precedence still
|
||||
apply. Stream closure without a turn terminal is not proof of success. Event
|
||||
replay uses the existing source receipts and never repeats provider work merely
|
||||
to recover recorded output.
|
||||
|
||||
If runnerd synthesizes a result when the provider stops, it publishes that result
|
||||
before the provider-turn terminal and publishes the run terminal last. The
|
||||
adapter can therefore retain the result while the matching turn still has
|
||||
authority. A late result must not reopen an already finalized turn.
|
||||
|
||||
Routine task completion and human-input requests must work under Conservative
|
||||
runner permissions. The isolated Claude runtime grants only the narrow task
|
||||
tools on the runner-owned bridge; it does not change general tool permissions.
|
||||
Questions must be created as durable interactions before the agent claims to be
|
||||
waiting. A direct Board comment reopening completed work has the same passive
|
||||
response-wait semantics as a comment on an open task, subject to the same source,
|
||||
identity, and governance checks. An automatic continuation is not a user reply.
|
||||
|
||||
Provider-turn identity separates recovery responses from earlier assistant
|
||||
output. A recovery turn cannot overwrite a delivered answer. File attachments
|
||||
and work products refresh in the visible conversation when delivered. Composer
|
||||
delivery uncertainty is reconciled by the exact durable client request ID;
|
||||
another comment cannot settle it, and newer draft text must be preserved.
|
||||
|
||||
The composer **Stop** action cancels the current response and verifies termination;
|
||||
it does not create a pause hold. An acknowledged intentional cancellation remains
|
||||
neutral even if teardown releases the run lease or returns no semantic result.
|
||||
**Pause work** separately controls future execution. A crash preventing progress
|
||||
is **Blocked**; **In Review** requires a concrete human decision.
|
||||
|
||||
Subtree pause and cancel record the authenticated board actor on each run they
|
||||
interrupt. A verified native stop must not become an unexplained failure simply
|
||||
because it came from a subtree action. The explicit pause hold still prevents
|
||||
future execution until Resume, and missing stop proof still blocks continuation.
|
||||
|
||||
### Provider continuity and bounded finalization
|
||||
|
||||
A permanently unusable native runner session may be replaced only with evidence that its predecessor is stopped and fenced, completed results and workspace state are preserved, required task history is available, and pending effects have been reconciled. A provider-native shell command or external write without a reliable outcome receipt is unknown. Unknown effects, integrity failures, and unverified process ownership never authorize speculative replay. Once automatic recovery is ruled out, Paperclip selects a conservative default: preserve recorded work, stop the affected task, and retain a durable no-replay hold. Unknown action outcomes remain unknown. No reconciliation form or user diagnosis is required.
|
||||
|
||||
Local Codex crash replacement can use a complete interrupted-turn inventory,
|
||||
authenticated process-stop evidence, and unchanged retained-state fingerprints.
|
||||
Only text and an exactly receipted task-completion call qualify for this path;
|
||||
unknown operations or partial transcripts do not. Replacement uses a fresh
|
||||
session and retires only the exact predecessor's obsolete recovery hold while
|
||||
recording the proof and successor lineage. Retained provider files are not edited.
|
||||
|
||||
Bootstrap retries, exact-checkpoint resumes, and fresh replacement sessions share three total provider attempts, including the original attempt. Linked run IDs, controller restarts, and duplicate wakes do not reset this budget. Automatic attempts retain the 30-second delay. Replacement scheduling and predecessor lineage commit together, with one successor per predecessor and admission through the normal task locks, authorization, pause, approval, and budget gates.
|
||||
|
||||
Provider execution and control-plane finalization have different clocks. A healthy provider can think or execute a long tool without output. Once execution settles, recovery and finalization control steps have a 60-second deadline, checked on startup and every 15 seconds. With a healthy database and scheduler, an abandoned transition must be repaired or surfaced within 90 seconds. Terminal persistence must not wait on provider cleanup or publication; a late finalizer cannot change a reassigned or closed task or release another run's locks. Historical ambiguous runs are never automatically replayed after an upgrade.
|
||||
|
||||
Every continuation carries the triggering request, ordered user direction, interaction outcomes, completed work, and explicit history coverage. A delivered message remains part of the task's request after its connection or approval resolves. The original title is background; a completed Notion read does not satisfy a later Gmail request. Author and source-trust boundaries survive rendering into both native and legacy prompts. Missing required history must be fetched before dispatch rather than described as complete.
|
||||
|
||||
### Interrupted conversation continuation
|
||||
|
||||
Before provider dispatch, chat-control admission retries transient database lock
|
||||
contention with up to 50 waits of 100 ms. Each attempt starts a new transaction
|
||||
and rechecks the current run and committed conversation-close evidence. No lock
|
||||
is held between attempts, and no provider call is retried. Queue claims remain
|
||||
nonblocking. Persistent contention retains the bounded admission failure, with
|
||||
an explicit database-lock error; missing or invalid source evidence still stops
|
||||
the run without retrying the admission check.
|
||||
|
||||
An interrupted conversation does not permanently block its task. For local conversational adapters, Paperclip starts a new bounded turn with the existing session when compatible, or the full task conversation when the session is unavailable. The prompt says: “Your previous run was interrupted. Continue from where you left off.” The agent decides what remains from the history and latest user request. Paperclip never automatically replays recorded tool calls. Unknown past action outcomes are not a task-wide execution gate, and no action-reconciliation questionnaire is required.
|
||||
|
||||
Shutdown, process loss, and provider failure use the existing durable failure retry counter and delay. Ordinary failure recovery permits at most two automatic retries in a failure chain. Accepted-interaction infrastructure recovery retains its existing bounded policy. Repeated scheduler visits reuse the same successor; restarting the server does not reset the counter. After exhaustion, automatic attempts stop. A new explicit user message can start a fresh run and failure budget. Productive max-turn continuation and confirmed workspace waits keep their separate existing semantics.
|
||||
|
||||
Real gates still apply: company and task ownership, active provider ownership, budget limits, agent availability, dependencies, pending approval/review paths, and explicit pause holds. Native runner reattachment and finalization retain their existing ownership protocol. Process, HTTP, and gateway adapters retain their recovery rules because invoking those adapters can itself repeat an external action rather than start a conversation turn.
|
||||
|
||||
An operator Stop waits for provider termination. Remote sandbox providers may return a stopped/deleted receipt after their control-plane operation completes. Paperclip binds that receipt to the company, run, and exact lease; successful file cleanup, a terminal run row, or an in-sandbox shutdown event is not sufficient. Legacy conversational runs receive their cancellation acknowledgement after all remote leases have confirmed termination. Stop alone never creates a continuation. A user message queued during remote cleanup is reconsidered when the provider confirms termination; it still passes normal admission and adopts pending comment IDs in order. Once stopped, the next explicit wake uses the same queue. A compatible saved ACP session can resume, and an unavailable or incompatible session can start fresh with the full task context. Run credentials and scratch paths remain scoped to the new run. A subtree pause requires Resume; a message does not bypass it.
|
||||
|
||||
For native conversations, an authenticated user message sent after the previous run finishes can retire its execution recovery holds and start a fresh turn. Hold retirement and the new run are atomic. The previous transcript, tool outcomes, and recovery history remain intact. This starts a new conversation; it does not replay tool calls with unknown outcomes.
|
||||
|
||||
Local recovery records a server-authored stop receipt before it clears a verified absent process identity. A new execution request invalidates that receipt before any process can spawn; recording a new process identity also invalidates it. Missing process IDs without a receipt still block admission. Remote execution continues to require termination receipts for every lease.
|
||||
|
||||
If cleanup or another execution gate is still pending, the message stays in its existing queue receipt. Startup and periodic scheduling reconsider up to 50 due receipts per pass, at most once per 30 seconds per receipt, without calling a model or resetting recovery attempts. Cleanup callbacks use the same admission path. The issue lock prevents concurrent workers from delivering an adopted or discarded receipt again. The queued-message area shows the current wait reason. Pauses, approvals, budgets, ownership, and external chat authorization remain enforced. A message sent before the run finished does not grant new post-stop authority.
|
||||
|
||||
Historical legacy interruption holds for conversational adapters no longer block new messages or Resume. Automatic classification uses the server-owned adapter identity saved atomically at run claim, the saved adapter invocation, or the continuation policy, never the agent’s current adapter settings. Missing historical adapter evidence retains the automatic hold; an explicit user continuation can retire it after proving the predecessor stopped. A terminal row with a live predecessor process, an unreleased environment lease, or failed/pending cleanup still blocks actual admission and Resume; a release timestamp alone does not prove cleanup succeeded. Retry scheduling can happen before cleanup, but grants no execution authority. Recovery folds their obsolete no-replay bookkeeping without changing task ownership, status, or automatically waking old work. The audit trail remains readable. Native integrity and ownership holds, and non-conversational adapter holds, remain enforced.
|
||||
|
||||
The server projection remains available for diagnostics. Normal working, finishing, and interaction waits add no badges or cards to task lists or feeds. Active transcript headers keep saying Working during automatic retry and execution confirmation; attempts, causes, and recovery decisions belong in the run log. Recovery uses the existing transcript and run log rather than adding a reconciliation form. A cancelled run that never started says “Couldn't start” instead of implying that the agent answered.
|
||||
|
||||
### Codex startup and provider state
|
||||
|
||||
Paperclip trusts the server-selected startup execution root in the isolated
|
||||
Codex configuration. Resolve that root on the execution host, including the
|
||||
main repository trust key for Git worktrees. Start the provider in that same
|
||||
root. This does not change sandbox permissions, tool authorization, secret
|
||||
access, or Codex's separate per-hook trust policy.
|
||||
|
||||
Codex retains the model conversation. Paperclip resumes with `excludeTurns: true`,
|
||||
reads lightweight thread state, and fetches paginated turn metadata or specific
|
||||
turn items only when execution reconciliation needs them. Unsupported
|
||||
or incomplete history is an explicit error, not evidence of idle execution.
|
||||
|
||||
The root-thread usage snapshot sent during resume belongs to its reported
|
||||
completed turn. Retain a bounded local diagnostic and use cumulative totals as
|
||||
a baseline; do not emit a warning or charge its historical `last` usage to the
|
||||
new run. Preserve the baseline across recovery of the same run and start a new
|
||||
delta when attaching a new run. Other stale-event and authority checks remain.
|
||||
|
||||
|
||||
### Explicit user continuation after execution failure
|
||||
|
||||
An execution recovery hold blocks automatic replay. A new authenticated user
|
||||
comment or exact failed-run Retry can authorize a fresh native or legacy
|
||||
conversation turn after the predecessor's
|
||||
execution is confirmed stopped. This is a new request, not another automatic
|
||||
attempt in the failed incident. The old attempt count and unknown action outcomes
|
||||
remain unchanged. Known non-conversation adapter evidence still requires its
|
||||
original reconciliation flow even if the agent's current settings change.
|
||||
Pre-upgrade runs with no adapter evidence may receive a new explicit user turn
|
||||
only after termination is proven; their old adapter and action outcomes remain
|
||||
unknown, and they do not gain automatic replay eligibility.
|
||||
|
||||
Admission validates the persisted comment's author, task, and time against every
|
||||
held predecessor. Retry validates the selected failed run's company, task, and
|
||||
agent and preserves that run's identity through admission and history loading.
|
||||
Duplicate Retry requests adopt the same successor. An agent-authored comment, an old queued request, or a generic
|
||||
system wake cannot release a hold. The source task keeps its assignee. Process
|
||||
ownership, active controllers, cleanup leases, pause, approval, budget, and normal
|
||||
execution gates still apply. Dependency-blocked interaction mode remains limited
|
||||
to its existing answer/triage contract.
|
||||
|
||||
The hold retirement, audit record, and new run commit together under the task
|
||||
lock. The new turn uses a fresh provider session and retains the latest user
|
||||
request, task history, completed work, and the interruption notice. It receives
|
||||
no instruction to repeat old tool calls. Later messages cannot reset the old
|
||||
incident's retry budget or create another automatic replacement for it.
|
||||
|
||||
Explicit continuation verifies local process identities for local runs. Remote runs
|
||||
instead require a provider termination receipt for every lease, with successful
|
||||
cleanup and no active ownership. This applies to both per-turn and warm native
|
||||
runners. A stop receipt retires only the settled cleanup owner for that exact company, run, provider, and sandbox resource, without changing its checkpoint or recorded action outcomes. Independent remote sandboxes have separate cleanup gates, including when one run owns multiple sandboxes. Successful pending-cleanup retries persist the same receipt and reconsider deferred user messages; a delivery failure never reverts successful provider cleanup. A failed checkpoint does not prevent destruction of a terminal run's isolated sandbox; busy ownership still prevents it.
|
||||
Missing receipts and failed cleanup retain the hold. Older providers that return
|
||||
no receipt remain supported but cannot authorize remote continuation. A terminal
|
||||
database status or a PID check on the wrong host is insufficient.
|
||||
No historical task is automatically awakened by this change.
|
||||
|
||||
Startup waits for provider plugin initialization before remote recovery and
|
||||
lease cleanup. The task's blocked notice offers Retry, and a refused retry
|
||||
shows the actual recovery hold. Each explicit user Retry can make one scoped
|
||||
cleanup attempt for its failed run even after automatic cleanup is exhausted.
|
||||
If that attempt fails, a later user Retry may try again after the provider
|
||||
recovers. The failed cleanup keeps the execution hold in place. Retry does not reset
|
||||
the automatic limit or clean up another task's leases. Provider shutdown must
|
||||
still be confirmed before a new conversation is admitted.
|
||||
|
||||
### Explicit Recovery Action
|
||||
|
||||
Paperclip opens an explicit recovery action when the system can identify a problem but cannot safely complete the work itself.
|
||||
|
|
@ -841,3 +1031,154 @@ For a board operator, the intended meaning is:
|
|||
- blockers explain waiting
|
||||
|
||||
That is the execution contract Paperclip should present to operators.
|
||||
|
||||
### Cancellation during native startup
|
||||
|
||||
Cancellation records a preparation fence while holding the run row lock. Native
|
||||
runtime selection checks that fence, the running status, and the current startup
|
||||
controller lease in the same transaction that creates the native coordinator.
|
||||
The native executor rechecks cancellation and terminal status when claiming the
|
||||
coordinator, before starting or attaching a provider.
|
||||
|
||||
A cancelled startup can continue from a newer authenticated user message after
|
||||
cleanup. The server requires either its explicit before-selection fence or an
|
||||
unclaimed native coordinator (zero attempts and controller generations, no
|
||||
controller, lease, or result). It also checks for contradictory launch/process
|
||||
evidence and verifies local cleanup or exact remote termination receipts. The
|
||||
preparer must have finished or its startup lease must have expired. A missing
|
||||
PID alone does not establish this proof.
|
||||
|
||||
The existing bounded saved-message worker rechecks this proof after restart.
|
||||
Admission atomically settles an unclaimed coordinator and admits one fresh turn,
|
||||
preserving history, unknown action outcomes, and attempt counts. Pauses, approvals,
|
||||
budgets, task ownership, and terminal task status still gate admission. No
|
||||
automatic provider replay is authorized by a cancelled startup.
|
||||
|
||||
### Delivering queued messages after a legacy run stops
|
||||
|
||||
The legacy queued-message Interrupt action accepts a null `targetRunId` when
|
||||
there is no active turn. It validates the queue identity and revision under
|
||||
the task lock and records durable board intent to send the saved queue. A
|
||||
run that stops between the queue read and the click is also accepted. The
|
||||
server never redirects interruption to an unrelated active run.
|
||||
Intentional interruption does not show the global cancelled/failed run toast;
|
||||
the queue control supplies its own delivery feedback.
|
||||
|
||||
This click can authorize a fresh conversation for messages written before
|
||||
the prior run stopped. It preserves the original message content and authors,
|
||||
and retains process/lease stop proofs, task ownership, pauses, approvals, and
|
||||
budget checks. Queue edits and discards remain authoritative until dispatch.
|
||||
Dispatch revalidates the consumed queue receipt against the operator, task,
|
||||
agent, message, and successor run; the operator need not be the message author.
|
||||
Repeated delivery attempts cannot create another successor after the queue
|
||||
is consumed. Native same-turn steering retains its active-target contract.
|
||||
|
||||
Legacy finalization retries deferred input after adapter and lease cleanup.
|
||||
The scheduler also revisits bounded batches of stranded queues after restart
|
||||
or a late enqueue. Both use normal admission; an existing queued successor
|
||||
owns the next turn even before it acquires the task execution lock. A recovery
|
||||
hold does not block an undelivered user message in a durable queue. The server
|
||||
validates the saved comment and its author, even if the queue began as a system
|
||||
wake. It can then start a fresh legacy conversation after proving the old
|
||||
process stopped. It preserves unknown action outcomes and does not replay
|
||||
comments already delivered to the failed run. A plain operator Stop still
|
||||
requires a new user action. The successor guard is scoped to the same agent so
|
||||
another agent's review participation keeps its independent recovery path.
|
||||
|
||||
An explicit queued-message Interrupt also grants one scoped cleanup retry for
|
||||
the stopped run. Old ephemeral leases whose cleanup predates provider stop
|
||||
receipts are rechecked through the recorded provider teardown path. Retained
|
||||
resources and sandboxes owned by another lease are not rechecked this way.
|
||||
Delivery still requires the provider's verified stop receipt. Periodic queue
|
||||
retries do not gain extra cleanup attempts, and the queue displays the server's
|
||||
waiting reason while cleanup remains unresolved.
|
||||
|
||||
The legacy task recovery notice shows “Automatic recovery of this task stopped.” in
|
||||
a bordered container with Retry for a failed or timed-out run. A failed Retry
|
||||
shows its error in the same container. New user messages and saved undelivered
|
||||
messages pass normal admission independently of automatic recovery exhaustion.
|
||||
|
||||
### Operator identity and permission for manual dispatch
|
||||
|
||||
A legacy queued-message Interrupt is a new instruction from the user who clicks
|
||||
it. The new run uses that user's execution identity, including when someone else
|
||||
wrote the queued messages. Message bodies and historical authors stay unchanged.
|
||||
The task page and pipeline conversations both permit Interrupt after the target
|
||||
run stops and submit the queue's current revision.
|
||||
Startup validates the consumed queue receipt against the new run, company,
|
||||
agent, task, clicking user, and delivered message IDs. Automatic retries inherit
|
||||
the resulting execution identity through the ordinary run identity history.
|
||||
|
||||
Starting an existing agent requires `agent:wake`, which active non-viewer board
|
||||
members have within their company. Both wake endpoints use this action instead
|
||||
of `agents:create`. An exact task retry also checks `issue:comment` on the task
|
||||
from the stored failed run and verifies that its assigned agent has not changed.
|
||||
External chat retries retain their additional conversation authorization.
|
||||
Ordinary board wake requests also persist the clicking user's identity, so
|
||||
adopting another author's queued message cannot change their execution authority.
|
||||
If that wake merges into an older deferred request, the same transaction updates
|
||||
the request's execution requester to the clicking user.
|
||||
Manual wake requests wait for their own run and execution identity. They do not
|
||||
merge into an agent's active run, with or without a task.
|
||||
Private agent conversations retain their owner-only wake and retry checks.
|
||||
|
||||
These actions do not grant permission to hire agents or change their settings.
|
||||
Each action during execution still checks the agent's authority and the
|
||||
responsible user's authority. A denied retry returns before dispatch; it does
|
||||
not create a new failed run or change the task's state.
|
||||
|
||||
### Native controller restart ownership
|
||||
|
||||
The controller persists a newly spawned runner's process identity before
|
||||
waiting for provider startup. An abrupt controller exit during session opening
|
||||
can then recover through the same exact process-identity checks as an active turn.
|
||||
|
||||
Both graceful and hot restarts detach the old controller from native sessions.
|
||||
If shutdown begins while a provider session is opening, its eventual publication
|
||||
honors the pending detachment before dispatching a turn. Once detached, an old
|
||||
execution finalizer cannot suspend or signal the durable runner: the next
|
||||
controller must recover it through the authenticated ownership checks. This
|
||||
preserves active work and queued messages without treating a server restart as
|
||||
user cancellation.
|
||||
|
||||
Before either shutdown path exits, idle warm sessions close through their
|
||||
normal suspend-and-checkpoint path. Remote sessions therefore leave verified
|
||||
backup authority for the next controller even though their last run is already
|
||||
complete. Busy sessions use active-run adoption while they remain active; if a
|
||||
turn finishes during shutdown, its release checkpoints the session before
|
||||
returning instead of leaving a new idle owner behind. If checkpointing fails,
|
||||
the retained state continues to block unverified reuse.
|
||||
|
||||
### Warm sandbox continuity
|
||||
|
||||
A warm sandbox's shared workspace binding persists independently of the
|
||||
experimental isolated-workspaces UI. Ordinary workspace updates remain gated;
|
||||
the runtime can bind only a validated shared workspace in the issue's company
|
||||
and project. Follow-ups can therefore reuse the same sandbox and provider
|
||||
session. A staged provider package is reused only after the complete expected
|
||||
manifest and artifact hashes verify. A missing, changed, or incompatible package
|
||||
must be replaced and verified before launch.
|
||||
|
||||
Safe native replacement may clear a Blocked status only with a durable receipt
|
||||
that the same failed run projected that exact status version. Explicitly
|
||||
reasserting Blocked or changing its blockers advances the status version, even
|
||||
when the displayed status is unchanged. Adding a queued comment does not change
|
||||
that authority. A later block also suppresses replacement at scheduled, queued,
|
||||
and final dispatch gates. Queued and final native replacement dispatch also
|
||||
re-read dependency readiness, since new dependencies need not change the
|
||||
displayed task status. Old blocked rows without a receipt remain held; no
|
||||
historical status backfill is performed.
|
||||
|
||||
### Queued input after a native Stop
|
||||
|
||||
A run-only Stop ends the current response. It does not discard queued user
|
||||
messages or require a recovery incident. After the controller releases ownership
|
||||
and the old local process or remote environment has a verified stop record,
|
||||
Paperclip submits saved input through normal task admission, once, with the
|
||||
original user's authority. Pauses, task ownership, budgets, approvals, and
|
||||
execution recovery holds still apply. Unconfirmed cleanup does not start work.
|
||||
|
||||
The active session advertises steering only when its driver supports it. A
|
||||
transport method that rejects steering does not grant that capability. The
|
||||
queued-message control remains mounted until the server accepts a steer request,
|
||||
so a rejected last-row action keeps its message and visible error.
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ task.run
|
|||
│ ├── environment.startup
|
||||
│ │ ├── environment.acquire
|
||||
│ │ └── environment.workspace.realize
|
||||
│ ├── skills.prepare
|
||||
│ ├── heartbeat.prepare_before_environment
|
||||
│ ├── heartbeat.prepare_after_environment
|
||||
│ └── native.coordinator.claim
|
||||
|
|
@ -753,16 +754,93 @@ To add a name or an enum value, extend the literal constant in
|
|||
|
||||
### Known behavior: aggregate retained body bytes
|
||||
|
||||
The HTTP/2 bridge bounds retained body bytes for one route only. Each route
|
||||
holds up to 8,388,608 bytes (8 MiB) at its own peak (see
|
||||
`HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` in `http2-bridge-server.ts`). The host
|
||||
process admits up to 128 concurrent routes (see
|
||||
`DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES` in `plugin-worker-manager.ts`). The
|
||||
process can therefore retain up to 1,073,741,824 bytes (1 GiB) of body data
|
||||
across every route at the same time.
|
||||
Each HTTP/2 bridge route holds up to 168,820,736 bytes (161 MiB) at its own
|
||||
peak (see `HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` in `http2-bridge-server.ts`).
|
||||
The host process admits up to 128 concurrent routes (see
|
||||
`DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES` in `plugin-worker-manager.ts`). Those
|
||||
two figures alone would let the process retain up to 21,609,054,208 bytes
|
||||
(about 20.1 GiB) of body data across every route at the same time.
|
||||
|
||||
The process does not reach that figure, on two levels.
|
||||
`HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES` (`http2-bridge-server.ts`) enforces a
|
||||
real, live ledger: 1,073,741,824 bytes (1 GiB) across every route, not merely
|
||||
an accepted paper ceiling. Every HTTP/2 stream creates one `BridgeBodyReservation` owner over
|
||||
its lifetime, and every source-level full-body buffer that stream retains —
|
||||
its request-body chunk array, the concatenated request body, the
|
||||
response-body chunk array, and the concatenated response body — reserves
|
||||
against that one owner before it allocates. A reservation that would pass the
|
||||
process total is denied before it copies anything, and the host answers 503
|
||||
instead of accepting the body. The reservation stays live for the response
|
||||
body until the HTTP/2 write actually finishes flowing to the peer or the
|
||||
stream closes, not merely until the write call returns, so a slow or
|
||||
backpressured peer cannot hold response bytes in memory the ledger no longer
|
||||
counts.
|
||||
|
||||
`HTTP2_BRIDGE_MAX_ROUTE_BODY_BYTES` adds a second, per-route ledger on top of
|
||||
that process-wide one: each route's own reservations also check a ceiling
|
||||
scoped to that one route (its own 168,820,736-byte peak from above), so one
|
||||
busy or malicious route can pass its own ceiling and get denied with a 503,
|
||||
but it can never spend the whole process-wide total and deny every sibling
|
||||
route admission. This accounting covers source-level full-body buffers only:
|
||||
internal Node.js and Undici copies (socket buffers, HTTP/2 frame buffers,
|
||||
decompression buffers) stay outside it.
|
||||
|
||||
The generated gateway process inside the sandbox (`getSandboxCallbackBridgeServerSource`
|
||||
in `sandbox-callback-bridge.ts`) enforces its own separate ledger, independent
|
||||
of the two host-side ledgers above: each side bounds only the memory in its
|
||||
own process. `readBodyBytes` reserves a request body's chunk bytes as they
|
||||
arrive, then reserves the concatenated buffer's own byte count before
|
||||
`Buffer.concat` allocates it, against a ceiling of `maxBodyBytes * 8` (4
|
||||
concurrent bodies, each counted twice for its two live copies). A denied
|
||||
reservation answers 503 with no forward call. Each request handler releases
|
||||
its own reservation once the whole request settles: a completed response, a
|
||||
thrown error, a client abort, or a deadline timeout all reach the same
|
||||
release call.
|
||||
|
||||
This is accepted, known behavior. The process tracks no aggregate byte
|
||||
ledger across routes: a per-route bound stops one busy route from starving
|
||||
another route's own budget, but the host enforces no smaller ceiling on the
|
||||
sum across every route.
|
||||
Keep every dimension low-cardinality and free of user content.
|
||||
|
||||
### Shared skill preparation
|
||||
|
||||
`skills.prepare` measures the shared inventory listing and runtime materialization
|
||||
inside `task.prepare`. It is also contained in the broader
|
||||
`heartbeat.prepare_before_environment` interval; do not add those two durations.
|
||||
Preparation failures emit a failed span even when no native session starts.
|
||||
It carries no skill contents, identifiers, locations, or credentials. It uses the
|
||||
existing run performance events and operator-configured OpenTelemetry endpoint;
|
||||
no first-party Telemetry event is added.
|
||||
|
||||
Runtime preparation refreshes the company inventory once per listing. Local and
|
||||
catalog directories remain direct sources, so edits are visible on the next
|
||||
preparation. Explicit version selections still use their stored snapshots.
|
||||
|
||||
Reconstructed skills use `__runtime_cache_v1__/<skill-id>/<fingerprint>/files`
|
||||
beneath company skill storage, with a sibling manifest of paths, sizes, and SHA-256
|
||||
content digests. Every warm hit validates the manifest and exact file contents;
|
||||
it does not fetch upstream, rewrite files, or remove directories. The fingerprint
|
||||
includes installed source identity, revision, file inventory, and stored Markdown,
|
||||
and excludes display names, stars, and general update timestamps. Manifests stay
|
||||
outside the directory delivered to agents.
|
||||
|
||||
GitHub and skills.sh imports are cached only when pinned to a full commit SHA.
|
||||
Remote freshness is explicit: update or reimport selects a new revision, including
|
||||
supporting-file-only changes. A branch advancing upstream does not change an
|
||||
installed revision. Legacy mutable refs retain uncached behavior until updated.
|
||||
URL-only skills use stored Markdown. An unavailable new revision reports missing;
|
||||
it never silently reuses an older revision. Stored `SKILL.md` remains a fallback,
|
||||
but missing supporting files prevent publication of a reusable partial cache.
|
||||
|
||||
Builds publish read-only files and directories from unique staging directories.
|
||||
A skill-scoped lock serializes builds and cleanup across processes. Cold builders
|
||||
recheck that the skill still exists under its original key before reading files
|
||||
and before atomic publication. Existing valid
|
||||
revisions stay readable during updates. Invalid entries are quarantined in the
|
||||
same skill cache root for inspection; rename/removal cleans up that skill's cache.
|
||||
Read-only listings validate caches without downloading or repairing them. A
|
||||
publication lock left by an abruptly terminated process is reported for operator
|
||||
cleanup; remove it only after confirming its recorded PID is no longer running.
|
||||
|
||||
Run `pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-skill-preparation.ts` for an isolated embedded
|
||||
PostgreSQL benchmark with 114 mixed skills and at least 400 remote files. It
|
||||
reports one cold sample and ten warm samples (one in a new process), refresh and
|
||||
fetch counts, rebuilds, missing entries, and content checks. Upstream responses are
|
||||
deterministic fixtures; use real deployed run spans for user-facing latency.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue