mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
merge: align Runner D contract with current master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
3ef1f6276c
995 files changed
+488017
-15838
No files matched your search
@@ -208,7 +208,7 @@ is missing, the cloned app does not have the expected companies/issues/agents,
|
||||
or the user explicitly asks for the normal isolated-workspace database.
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree reseed --from-instance default --seed-mode full --yes
|
||||
npx paperclipai worktree reseed --from-instance default --seed-mode full --yes
|
||||
```
|
||||
|
||||
After reseed, restart through the managed runtime path. A reseed can copy
|
||||
|
||||
@@ -73,8 +73,8 @@ Required for live publishes:
|
||||
export AWS_REGION=us-east-1
|
||||
export PAPERCLIP_PAGE_BUCKET=paperclip-pages-prod
|
||||
export PAPERCLIP_PAGE_BASE_URL=https://pages.paperclip.ing
|
||||
export AWS_ACCESS_KEY_ID=...
|
||||
export AWS_SECRET_ACCESS_KEY=...
|
||||
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID=...
|
||||
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY=...
|
||||
```
|
||||
|
||||
Optional:
|
||||
@@ -82,15 +82,35 @@ Optional:
|
||||
```bash
|
||||
export PAPERCLIP_PAGE_DEFAULT_PREFIX=""
|
||||
export PAPERCLIP_PAGE_AWS_PROFILE=paperclip-page-uploader
|
||||
export PAPERCLIP_PAGE_AWS_SESSION_TOKEN=... # only with the namespaced key pair
|
||||
```
|
||||
|
||||
Credential resolution order inside `publish.sh`:
|
||||
|
||||
1. `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` + `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`
|
||||
(scoped to the helper's `aws` calls; the surrounding process identity is
|
||||
untouched)
|
||||
2. `PAPERCLIP_PAGE_AWS_PROFILE`, passed to `aws` as `--profile` (ambient
|
||||
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` /
|
||||
`AWS_PROFILE` are stripped from the helper's `aws` calls so the named
|
||||
profile always wins)
|
||||
3. The ambient AWS credential chain
|
||||
|
||||
Setting both the namespaced key pair and `PAPERCLIP_PAGE_AWS_PROFILE` is an
|
||||
error.
|
||||
|
||||
Recommended Paperclip secret names:
|
||||
|
||||
- `paperclip-page-aws-access-key-id`
|
||||
- `paperclip-page-aws-secret-access-key`
|
||||
|
||||
Bind those secrets into publisher agents as `AWS_ACCESS_KEY_ID` and
|
||||
`AWS_SECRET_ACCESS_KEY`. Do not reuse Paperclip's internal S3 attachment/object
|
||||
Bind those secrets into publisher agents as `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID`
|
||||
and `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`. Never bind them as the global
|
||||
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` names: static env keys override
|
||||
`AWS_PROFILE` in the AWS CLI and every SDK, so global names silently switch the
|
||||
whole agent run — and every subprocess — to the page-uploader identity and
|
||||
break access to anything the uploader cannot reach (Secrets Manager, STS role
|
||||
use, other buckets). Do not reuse Paperclip's internal S3 attachment/object
|
||||
storage credentials.
|
||||
|
||||
## AWS Setup
|
||||
@@ -473,27 +493,29 @@ Create secrets from environment variables so values do not land in shell history
|
||||
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID="$(jq -r '.AccessKey.AccessKeyId' /tmp/paperclip-page-uploader-key.json)"
|
||||
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY="$(jq -r '.AccessKey.SecretAccessKey' /tmp/paperclip-page-uploader-key.json)"
|
||||
|
||||
pnpm paperclipai secrets create \
|
||||
npx paperclipai secrets create \
|
||||
--company-id <company-id> \
|
||||
--name paperclip-page-aws-access-key-id \
|
||||
--value-env PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID
|
||||
|
||||
pnpm paperclipai secrets create \
|
||||
npx paperclipai secrets create \
|
||||
--company-id <company-id> \
|
||||
--name paperclip-page-aws-secret-access-key \
|
||||
--value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
|
||||
```
|
||||
|
||||
Bind runtime env to publishing agents:
|
||||
Bind runtime env to publishing agents. Use the namespaced names — never the
|
||||
global `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, which would shadow the
|
||||
host `AWS_PROFILE` identity for the entire agent run:
|
||||
|
||||
```json
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": {
|
||||
"PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID": {
|
||||
"type": "secret_ref",
|
||||
"secretId": "<access-key-secret-id>",
|
||||
"version": "latest"
|
||||
},
|
||||
"AWS_SECRET_ACCESS_KEY": {
|
||||
"PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY": {
|
||||
"type": "secret_ref",
|
||||
"secretId": "<secret-key-secret-id>",
|
||||
"version": "latest"
|
||||
@@ -510,7 +532,7 @@ Bind runtime env to publishing agents:
|
||||
Create or update the company skill from this package:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai skills create \
|
||||
npx paperclipai skills create \
|
||||
--company-id <company-id> \
|
||||
--name "Paperclip Page" \
|
||||
--slug paperclip-page \
|
||||
@@ -521,7 +543,7 @@ pnpm paperclipai skills create \
|
||||
Attach it to an agent:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai skills agent sync <agent-id-or-shortname> \
|
||||
npx paperclipai skills agent sync <agent-id-or-shortname> \
|
||||
--company-id <company-id> \
|
||||
--skill paperclip-page
|
||||
```
|
||||
|
||||
@@ -19,10 +19,20 @@ host, for example `https://pages.paperclip.ing/<slug>/`.
|
||||
- `PAPERCLIP_PAGE_BUCKET`
|
||||
- `PAPERCLIP_PAGE_BASE_URL`
|
||||
- `AWS_REGION`
|
||||
- AWS credentials via Paperclip Secrets or an approved AWS vault
|
||||
- `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` and `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`
|
||||
with the page-uploader credentials from Paperclip Secrets
|
||||
- Optional environment variables:
|
||||
- `PAPERCLIP_PAGE_DEFAULT_PREFIX`
|
||||
- `PAPERCLIP_PAGE_AWS_PROFILE`
|
||||
- `PAPERCLIP_PAGE_AWS_PROFILE` (alternative to the namespaced key pair)
|
||||
- `PAPERCLIP_PAGE_AWS_SESSION_TOKEN` (only together with the namespaced key
|
||||
pair)
|
||||
|
||||
Do not bind the page-uploader credentials as global `AWS_ACCESS_KEY_ID` /
|
||||
`AWS_SECRET_ACCESS_KEY`: static env keys take precedence over `AWS_PROFILE` in
|
||||
every AWS SDK, so global names silently replace the host identity for every
|
||||
process in the agent run. The namespaced variables scope the uploader identity
|
||||
to this helper only. The ambient credential chain still works as a fallback
|
||||
when none of the `PAPERCLIP_PAGE_AWS_*` credential variables are set.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
@@ -15,6 +15,15 @@ Required environment for live publish:
|
||||
|
||||
Optional environment:
|
||||
PAPERCLIP_PAGE_DEFAULT_PREFIX, PAPERCLIP_PAGE_AWS_PROFILE
|
||||
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID, PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY,
|
||||
PAPERCLIP_PAGE_AWS_SESSION_TOKEN
|
||||
|
||||
Credential resolution for aws calls made by this helper:
|
||||
1. PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID + PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
|
||||
(used only by this helper; ambient AWS_PROFILE/AWS_* identity is untouched)
|
||||
2. PAPERCLIP_PAGE_AWS_PROFILE (passed as --profile; ambient AWS_* identity
|
||||
variables are stripped from the helper's aws calls)
|
||||
3. Ambient AWS credential chain (env keys, profile, instance role)
|
||||
|
||||
Options:
|
||||
--slug SLUG Lowercase URL slug. Allowed: a-z, 0-9, hyphen.
|
||||
@@ -125,9 +134,26 @@ join_prefix() {
|
||||
}
|
||||
|
||||
aws_base_args=()
|
||||
aws_env_unset=()
|
||||
aws_env_overrides=()
|
||||
|
||||
aws_cli() {
|
||||
aws "${aws_base_args[@]}" "$@"
|
||||
local name pair
|
||||
if [[ ${#aws_env_unset[@]} -gt 0 || ${#aws_env_overrides[@]} -gt 0 ]]; then
|
||||
# Scope the page-uploader identity to this helper's aws calls only, and
|
||||
# drop the ambient identity variables that would otherwise mix with or
|
||||
# shadow the configured credential source. Apply the overrides with shell
|
||||
# builtins in a subshell — passing them to an external `env` command would
|
||||
# expose the credential values in its argv (world-readable via
|
||||
# /proc/<pid>/cmdline) while it runs.
|
||||
(
|
||||
for name in "${aws_env_unset[@]}"; do unset "$name"; done
|
||||
for pair in "${aws_env_overrides[@]}"; do export "$pair"; done
|
||||
exec aws "${aws_base_args[@]}" "$@"
|
||||
)
|
||||
else
|
||||
aws "${aws_base_args[@]}" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
object_exists() {
|
||||
@@ -297,6 +323,18 @@ default_prefix="$(normalize_default_prefix "${PAPERCLIP_PAGE_DEFAULT_PREFIX:-}")
|
||||
[[ -n "$base_url" ]] || die "PAPERCLIP_PAGE_BASE_URL is required"
|
||||
base_url="$(normalize_base_url "$base_url")"
|
||||
|
||||
page_access_key_id="${PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID:-}"
|
||||
page_secret_access_key="${PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY:-}"
|
||||
if [[ -n "$page_access_key_id" || -n "$page_secret_access_key" ]]; then
|
||||
[[ -n "$page_access_key_id" && -n "$page_secret_access_key" ]] ||
|
||||
die "PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID and PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY must be set together"
|
||||
[[ -z "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]] ||
|
||||
die "set PAPERCLIP_PAGE_AWS_PROFILE or the PAPERCLIP_PAGE_AWS_* key pair, not both"
|
||||
fi
|
||||
if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" && -z "$page_access_key_id" ]]; then
|
||||
die "PAPERCLIP_PAGE_AWS_SESSION_TOKEN requires the PAPERCLIP_PAGE_AWS_* key pair"
|
||||
fi
|
||||
|
||||
explicit_slug=0
|
||||
if [[ -n "$slug_arg" ]]; then
|
||||
explicit_slug=1
|
||||
@@ -310,8 +348,18 @@ if [[ "$dry_run" == "0" ]]; then
|
||||
require_command curl
|
||||
[[ -n "$region" ]] || die "AWS_REGION is required for live publish"
|
||||
aws_base_args=(--region "$region")
|
||||
if [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
|
||||
if [[ -n "$page_access_key_id" ]]; then
|
||||
aws_env_unset=(AWS_PROFILE AWS_SESSION_TOKEN)
|
||||
aws_env_overrides=(
|
||||
AWS_ACCESS_KEY_ID="$page_access_key_id"
|
||||
AWS_SECRET_ACCESS_KEY="$page_secret_access_key"
|
||||
)
|
||||
if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" ]]; then
|
||||
aws_env_overrides+=(AWS_SESSION_TOKEN="$PAPERCLIP_PAGE_AWS_SESSION_TOKEN")
|
||||
fi
|
||||
elif [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
|
||||
aws_base_args+=(--profile "$PAPERCLIP_PAGE_AWS_PROFILE")
|
||||
aws_env_unset=(AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE)
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -131,6 +131,215 @@ test("rejects hidden files in the source tree", () => {
|
||||
assert.match(result.output, /hidden files and dot paths are not allowed/);
|
||||
});
|
||||
|
||||
test("namespaced page keys require both halves of the pair", () => {
|
||||
const result = runPublish(
|
||||
[createSite(), "--slug", "demo-page", "--dry-run"],
|
||||
{ PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER" },
|
||||
);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.output, /must be set together/);
|
||||
});
|
||||
|
||||
test("namespaced session token requires the namespaced key pair", () => {
|
||||
const result = runPublish(
|
||||
[createSite(), "--slug", "demo-page", "--dry-run"],
|
||||
{ PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-token" },
|
||||
);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.output, /requires the PAPERCLIP_PAGE_AWS_\* key pair/);
|
||||
});
|
||||
|
||||
test("namespaced page keys conflict with PAPERCLIP_PAGE_AWS_PROFILE", () => {
|
||||
const result = runPublish(
|
||||
[createSite(), "--slug", "demo-page", "--dry-run"],
|
||||
{
|
||||
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
|
||||
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",
|
||||
PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",
|
||||
},
|
||||
);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.output, /not both/);
|
||||
});
|
||||
|
||||
test("namespaced page keys are scoped to the helper's aws calls", () => {
|
||||
const siteDir = createSite();
|
||||
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
|
||||
tempDirs.add(binDir);
|
||||
const envDump = join(binDir, "aws-env.txt");
|
||||
|
||||
writeExecutable(
|
||||
join(binDir, "aws"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-<unset>}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-<unset>}"
|
||||
echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-<unset>}"
|
||||
echo "AWS_PROFILE=\${AWS_PROFILE:-<unset>}"
|
||||
} >"${envDump}"
|
||||
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
|
||||
shift 2
|
||||
done
|
||||
if [[ "$1" == "s3api" ]]; then
|
||||
echo "None"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "unexpected aws call: $*" >&2
|
||||
exit 1
|
||||
`,
|
||||
);
|
||||
writeExecutable(
|
||||
join(binDir, "curl"),
|
||||
`#!/usr/bin/env bash
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runPublish([siteDir, "--slug", "demo-page"], {
|
||||
AWS_REGION: "us-east-1",
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",
|
||||
AWS_SECRET_ACCESS_KEY: "ambient-secret",
|
||||
AWS_SESSION_TOKEN: "ambient-session-token",
|
||||
AWS_PROFILE: "ambient-profile",
|
||||
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
|
||||
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
|
||||
const seen = readFileSync(envDump, "utf8");
|
||||
assert.match(seen, /^AWS_ACCESS_KEY_ID=AKIAPAGEUPLOADER$/m);
|
||||
assert.match(seen, /^AWS_SECRET_ACCESS_KEY=page-secret$/m);
|
||||
assert.match(seen, /^AWS_SESSION_TOKEN=<unset>$/m);
|
||||
assert.match(seen, /^AWS_PROFILE=<unset>$/m);
|
||||
});
|
||||
|
||||
test("credential values never pass through an external env command's argv", () => {
|
||||
const siteDir = createSite();
|
||||
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
|
||||
tempDirs.add(binDir);
|
||||
const envArgvDump = join(binDir, "env-argv.txt");
|
||||
|
||||
writeExecutable(
|
||||
join(binDir, "aws"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
|
||||
shift 2
|
||||
done
|
||||
if [[ "$1" == "s3api" ]]; then
|
||||
echo "None"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "unexpected aws call: $*" >&2
|
||||
exit 1
|
||||
`,
|
||||
);
|
||||
writeExecutable(
|
||||
join(binDir, "curl"),
|
||||
`#!/usr/bin/env bash
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
// Shim env: record every argv it is invoked with, then behave normally.
|
||||
// Credentials in that argv would be world-readable via /proc/<pid>/cmdline.
|
||||
writeExecutable(
|
||||
join(binDir, "env"),
|
||||
`#!/bin/bash
|
||||
printf '%s\\n' "$@" >>"${envArgvDump}"
|
||||
exec /usr/bin/env "$@"
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runPublish([siteDir, "--slug", "demo-page"], {
|
||||
AWS_REGION: "us-east-1",
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
|
||||
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret-argv-canary",
|
||||
PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-argv-canary",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
|
||||
const argvSeen = existsSync(envArgvDump) ? readFileSync(envArgvDump, "utf8") : "";
|
||||
assert.doesNotMatch(argvSeen, /page-secret-argv-canary/);
|
||||
assert.doesNotMatch(argvSeen, /page-session-argv-canary/);
|
||||
assert.doesNotMatch(argvSeen, /AKIAPAGEUPLOADER/);
|
||||
});
|
||||
|
||||
test("PAPERCLIP_PAGE_AWS_PROFILE strips ambient static credentials", () => {
|
||||
const siteDir = createSite();
|
||||
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
|
||||
tempDirs.add(binDir);
|
||||
const envDump = join(binDir, "aws-env.txt");
|
||||
|
||||
writeExecutable(
|
||||
join(binDir, "aws"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
profile="<none>"
|
||||
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
|
||||
if [[ "$1" == "--profile" ]]; then
|
||||
profile="$2"
|
||||
fi
|
||||
shift 2
|
||||
done
|
||||
{
|
||||
echo "PROFILE_ARG=$profile"
|
||||
echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-<unset>}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-<unset>}"
|
||||
echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-<unset>}"
|
||||
echo "AWS_PROFILE=\${AWS_PROFILE:-<unset>}"
|
||||
} >"${envDump}"
|
||||
if [[ "$1" == "s3api" ]]; then
|
||||
echo "None"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "unexpected aws call: $*" >&2
|
||||
exit 1
|
||||
`,
|
||||
);
|
||||
writeExecutable(
|
||||
join(binDir, "curl"),
|
||||
`#!/usr/bin/env bash
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runPublish([siteDir, "--slug", "demo-page"], {
|
||||
AWS_REGION: "us-east-1",
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",
|
||||
AWS_SECRET_ACCESS_KEY: "ambient-secret",
|
||||
AWS_SESSION_TOKEN: "ambient-session-token",
|
||||
AWS_PROFILE: "ambient-profile",
|
||||
PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
|
||||
const seen = readFileSync(envDump, "utf8");
|
||||
assert.match(seen, /^PROFILE_ARG=paperclip-page-uploader$/m);
|
||||
assert.match(seen, /^AWS_ACCESS_KEY_ID=<unset>$/m);
|
||||
assert.match(seen, /^AWS_SECRET_ACCESS_KEY=<unset>$/m);
|
||||
assert.match(seen, /^AWS_SESSION_TOKEN=<unset>$/m);
|
||||
assert.match(seen, /^AWS_PROFILE=<unset>$/m);
|
||||
});
|
||||
|
||||
test("live publish writes state before URL verification", () => {
|
||||
const siteDir = createSite();
|
||||
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
|
||||
|
||||
@@ -11,7 +11,11 @@ description: >
|
||||
Write the Discord release announcement for the **stable** Paperclip release.
|
||||
|
||||
This is the companion to `.agents/skills/release-changelog/SKILL.md`. That skill
|
||||
generates the file at `releases/vYYYY.MDD.P.md`. This skill turns that file into
|
||||
writes the changelog — during the beta soak it lives at
|
||||
`releases/beta/v{beta-version}.md` on the `release-notes/v{beta-version}`
|
||||
branch, and after the stable ships a canonicalization PR renames it to
|
||||
`releases/vYYYY.MDD.P.md` (see that skill's Channel Process section). This
|
||||
skill turns that file into
|
||||
a single copy-pasteable Discord block, in dotta's voice, and posts it as the
|
||||
`discord_announcement` document on the release issue.
|
||||
|
||||
@@ -27,8 +31,9 @@ current Paperclip work — not invented.
|
||||
|
||||
## When to use
|
||||
|
||||
- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the
|
||||
release worktree/PR.
|
||||
- After `release-changelog` has produced the changelog (beta-keyed on the
|
||||
`release-notes/v{beta-version}` branch during the soak, or the
|
||||
canonicalized `releases/vYYYY.MDD.P.md` after the stable ships).
|
||||
- When the release issue (the one assigned by the release routine) asks for a
|
||||
Discord announcement, or has a `discord_announcement` document that needs to
|
||||
be refreshed for a new date/version.
|
||||
@@ -123,6 +128,12 @@ Notes on the template:
|
||||
|
||||
- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are
|
||||
part of the brand — keep them.
|
||||
- Name the install channels somewhere in the post: `npx paperclipai@latest`
|
||||
for the stable, `@beta` / `@nightly` / `@canary` for earlier access, and
|
||||
Docker `:latest` moving **only** on stable releases.
|
||||
- The FULL RELEASE NOTES link points at `releases/v{VERSION}.md` on
|
||||
`master` — that file exists only after the post-stable canonicalization
|
||||
PR merges. Merge it before the announcement is posted.
|
||||
- Sections may be UPPERCASE or Title Case — dotta has used both. Pick a style
|
||||
and stay consistent within a single post.
|
||||
- Use `||@everyone||` (Discord spoiler-wrapped) at the very end so it pings
|
||||
@@ -163,7 +174,8 @@ Mimic this register; do not invent a "professional" tone.
|
||||
(follow the twitter, intros, beta sign-ups). No real ask → drop it.
|
||||
- **Community** is the same contributors list that's in the changelog file,
|
||||
fenced in a triple-backtick block, comma-separated `@username, @username`.
|
||||
Exclude bots and Paperclip founders, same rules as the changelog skill.
|
||||
Exclude bots and the specific folks on the changelog skill's canonical
|
||||
exclusion list — same rules.
|
||||
- **The "In Summary" mission line** evolves slowly. Use the most recent
|
||||
variant unless dotta tells you otherwise. Recent variants:
|
||||
- "PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK"
|
||||
@@ -174,15 +186,19 @@ Mimic this register; do not invent a "professional" tone.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`.
|
||||
Use the version and contributor list from that file — never re-derive them.
|
||||
1. Read the matching changelog produced by `release-changelog` — the
|
||||
beta-keyed file during the soak, `releases/vYYYY.MDD.P.md` once
|
||||
canonicalized. Use the version and contributor list from that file —
|
||||
never re-derive them.
|
||||
2. Resolve the parent `release` case with key `paperclip-release:vYYYY.MDD.P`.
|
||||
If it does not exist and Cases are enabled, create it using the schema in
|
||||
`.agents/skills/release-changelog/SKILL.md` before creating child cases.
|
||||
3. Read the **release issue thread** (the one assigned to you that ran the
|
||||
release routine) — comments + linked issues + recent issues in the company
|
||||
are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes,
|
||||
not invented ones.
|
||||
are the source for `WHATS NEXT` and `What's on my mind`. Commits already
|
||||
on `origin/master` **after** the beta source commit are prime "what's
|
||||
next" material: they are literally the next release's content. Pull real
|
||||
themes, not invented ones.
|
||||
4. Re-read the three verbatim examples below — they're the canonical voice.
|
||||
5. Draft the announcement using the template above.
|
||||
6. PUT it as the `discord_announcement` document on the release issue (see
|
||||
@@ -444,7 +460,8 @@ https://github.com/paperclipai/paperclip/blob/master/releases/v2026.427.0.md
|
||||
Before handing off:
|
||||
|
||||
1. Version + date match the matching `releases/vYYYY.MDD.P.md` exactly.
|
||||
2. Contributor list matches the changelog (same exclusions: bots, founders).
|
||||
2. Contributor list matches the changelog (same exclusions: bots and the
|
||||
changelog skill's canonical excluded-folks list).
|
||||
3. Highlights are a subset of the changelog Highlights — same shipped features,
|
||||
not invented or pre-alpha work.
|
||||
4. `WHATS NEXT` and `What's on my mind` are pulled from real recent issues /
|
||||
|
||||
@@ -32,14 +32,57 @@ Important rules:
|
||||
- do not derive versions from semver bump types
|
||||
- do not create canary changelog files
|
||||
|
||||
## Channel Process — Source Commit and File Location
|
||||
|
||||
Stables promote a **soaked beta**, so the changelog describes the beta's
|
||||
source commit, not the tip of `master`:
|
||||
|
||||
- The release **source** is the commit the newest `beta/v<beta-version>`
|
||||
tag points at (`{beta-src}` below). Resolve it with:
|
||||
|
||||
```bash
|
||||
git fetch origin --tags
|
||||
npm view paperclipai dist-tags # the beta dist-tag names the version
|
||||
git rev-parse 'beta/v{beta-version}^{commit}'
|
||||
```
|
||||
|
||||
- Commits on `master` after `{beta-src}` ship in the **next** release.
|
||||
Never include them; they are input for a "what's next" section, not the
|
||||
changelog.
|
||||
- During the soak, the file lives at `releases/beta/v{beta-version}.md`
|
||||
on the branch `release-notes/v{beta-version}` (PR to `master`). The
|
||||
release workflow pushes that branch with a generated skeleton when the
|
||||
beta publishes; work on it and rewrite the skeleton in place. If the
|
||||
branch does not exist (a beta cut before the automation), create it
|
||||
from `origin/master` and seed the skeleton:
|
||||
|
||||
```bash
|
||||
./scripts/draft-stable-notes.sh {beta-version}
|
||||
```
|
||||
|
||||
- The PR must merge to `master` **before** the stable is dispatched: the
|
||||
stable preflight reads the file from `master` and fails without it.
|
||||
- Never create `releases/vYYYY.MDD.P.md` yourself on this path — after
|
||||
the stable ships, the workflow opens a canonicalization PR that renames
|
||||
the beta-keyed file to it.
|
||||
- **Fix path exception** (patch releases from a `candidate/release-*`
|
||||
branch): there the notes *do* go directly on the candidate branch as
|
||||
`releases/vYYYY.MDD.P.md`, committed alongside the cherry-picked fixes.
|
||||
|
||||
## Step 0 — Idempotency Check
|
||||
|
||||
Before generating anything, check whether the file already exists:
|
||||
Before generating anything, check whether the changelog already exists:
|
||||
|
||||
```bash
|
||||
ls releases/vYYYY.MDD.P.md 2>/dev/null
|
||||
ls releases/beta/v{beta-version}.md 2>/dev/null # soak-window home
|
||||
ls releases/vYYYY.MDD.P.md 2>/dev/null # canonicalized / fix path
|
||||
git ls-remote origin 'refs/heads/release-notes/v{beta-version}'
|
||||
```
|
||||
|
||||
A `release-notes/v{beta-version}` branch holding only the generated
|
||||
skeleton is the normal starting state, not a conflict — rewrite it in
|
||||
place.
|
||||
|
||||
If it exists:
|
||||
|
||||
1. read it first
|
||||
@@ -49,13 +92,17 @@ If it exists:
|
||||
|
||||
## Step 1 — Determine the Stable Range
|
||||
|
||||
Find the last stable tag:
|
||||
Find the last stable tag and the beta source commit:
|
||||
|
||||
```bash
|
||||
git tag --list 'v*' --sort=-version:refname | head -1
|
||||
git log v{last}..HEAD --oneline --no-merges
|
||||
beta_src="$(git rev-parse 'beta/v{beta-version}^{commit}')"
|
||||
git log v{last}..${beta_src} --oneline --no-merges
|
||||
```
|
||||
|
||||
The changelog range is always `v{last}..{beta-src}` — never `..HEAD` and
|
||||
never `..origin/master`.
|
||||
|
||||
The stable version comes from one of:
|
||||
|
||||
- an explicit maintainer request
|
||||
@@ -76,8 +123,8 @@ Collect release data from:
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
git log v{last}..HEAD --oneline --no-merges
|
||||
git log v{last}..HEAD --format="%H %s" --no-merges
|
||||
git log v{last}..{beta-src} --oneline --no-merges
|
||||
git log v{last}..{beta-src} --format="%H %s" --no-merges
|
||||
ls .changeset/*.md | grep -v README.md
|
||||
gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels
|
||||
```
|
||||
@@ -94,10 +141,10 @@ Look for:
|
||||
Key commands:
|
||||
|
||||
```bash
|
||||
git diff --name-only v{last}..HEAD -- packages/db/src/migrations/
|
||||
git diff v{last}..HEAD -- packages/db/src/schema/
|
||||
git diff v{last}..HEAD -- server/src/routes/ server/src/api/
|
||||
git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
|
||||
git diff --name-only v{last}..{beta-src} -- packages/db/src/migrations/
|
||||
git diff v{last}..{beta-src} -- packages/db/src/schema/
|
||||
git diff v{last}..{beta-src} -- server/src/routes/ server/src/api/
|
||||
git log v{last}..{beta-src} --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
|
||||
```
|
||||
|
||||
If breaking changes are detected, flag them prominently — they must appear in the
|
||||
@@ -143,6 +190,14 @@ Rules:
|
||||
|
||||
## Step 5 — Write the File
|
||||
|
||||
The **file path** is the beta-keyed one from the Channel Process section
|
||||
(`releases/beta/v{beta-version}.md`), but the **content** is titled with
|
||||
the planned stable version. Resolve it with
|
||||
`./scripts/release.sh stable --date {planned-promotion-date} --print-version`
|
||||
(promotion is normally the beta publish date plus the 3-day soak). If the
|
||||
promotion date slips, the version re-resolves at dispatch — the beta-keyed
|
||||
filename makes that harmless; refresh the title when it happens.
|
||||
|
||||
The opening line of the changelog must be an H1 of the format `# Paperclip {version}`
|
||||
(no braces), e.g. `# Paperclip v2026.618.0`. Always include the `Paperclip ` prefix and
|
||||
the `v` on the version.
|
||||
@@ -177,14 +232,18 @@ The `Contributors` section should always be included. List every person who auth
|
||||
commits in the release range, @-mentioning them by their **GitHub username** (not their
|
||||
real name or email). To find GitHub usernames:
|
||||
|
||||
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
|
||||
1. Extract usernames from merge commit messages: `git log v{last}..{beta-src} --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
|
||||
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
|
||||
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.
|
||||
|
||||
**Never expose contributor email addresses.** Use `@username` only.
|
||||
|
||||
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list.
|
||||
Exclude Paperclip founders from the list (e.g. `cryppadotta`, `forgottendev`, `devinfoley`, `sockmonster`, `scotttong`)
|
||||
Exclude specific folks from the list — the Contributors section credits
|
||||
community contributors only. The canonical exclusion list (keep it here;
|
||||
the Discord skill defers to it):
|
||||
`cryppadotta`, `forgottendev`, `devinfoley`, `sockmonster`, `scotttong`,
|
||||
`nguyenm7`, `nickyleach`, `tonio-alucema`
|
||||
|
||||
List contributors in alphabetical order by GitHub username (case-insensitive).
|
||||
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
"runtimeArgs": ["-c", "TMPDIR=/tmp pnpm dev"],
|
||||
"port": 3108,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "ui-preview",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@paperclipai/ui", "exec", "vite"],
|
||||
"port": 5188,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -188,24 +188,6 @@ Use in property rows, comment headers, assignee displays, and anywhere a user/ag
|
||||
**File:** `CompanySwitcher.tsx`
|
||||
**Usage:** Company selector dropdown in sidebar header.
|
||||
|
||||
### AgentCapsule
|
||||
|
||||
**File:** `AgentCapsule.tsx`
|
||||
**Props:** `state: "slot" | "configured" | "online"`, `gradient?: 1–10`, `size?: "sm" | "md" | "lg" | {width,height}`, `glow?: "green" | "blue"`
|
||||
**Usage:** The brand "capsule is the agent" pill; evolves in place across onboarding steps. Fill uses `--agent-Na/Nb` gradient tokens; honors `prefers-reduced-motion`.
|
||||
|
||||
### Onboarding primitives (OnboardingCard, OnboardingHeading, Stepper, Chip, ChoiceCard, ConnectorRow)
|
||||
|
||||
**File:** `onboarding/OnboardingPrimitives.tsx`
|
||||
**Usage:** Presentational pieces for the full-screen onboarding flow (`onboarding/OnboardingFlow.tsx`): the 560px card frame (`--sz-560px`), display heading + lede (text-4xl), 3-segment stepper, selectable mission chips, selectable choice cards, and connector rows. Bespoke dimensions route through verbatim `--sz-*` tokens; fields inside the flow use the shared Input/Textarea/Select/Label primitives.
|
||||
|
||||
```tsx
|
||||
<OnboardingCard>
|
||||
<Stepper step={2} />
|
||||
<OnboardingHeading title="Create your first agent" lede="..." />
|
||||
</OnboardingCard>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout Components
|
||||
|
||||
@@ -4,5 +4,11 @@ SERVE_UI=false
|
||||
BETTER_AUTH_SECRET=paperclip-dev-secret
|
||||
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=paperclip-dev-tool-action-signing-secret-change-me
|
||||
|
||||
# Process-wide protection for expensive full-tree workspace Git scans.
|
||||
# PAPERCLIP_WORKSPACE_GIT_SCAN_CONCURRENCY=2
|
||||
# PAPERCLIP_WORKSPACE_GIT_SCAN_QUEUE_CAPACITY=32
|
||||
# PAPERCLIP_WORKSPACE_GIT_SCAN_TIMEOUT_MS=8000
|
||||
# PAPERCLIP_WORKSPACE_GIT_SCAN_CACHE_TTL_MS=10000
|
||||
|
||||
# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh)
|
||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
+10
-4
@@ -9,10 +9,16 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
ignore:
|
||||
# Ignore major version bumps — review those manually
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
groups:
|
||||
# Bundle all major version bumps into a single weekly PR so they
|
||||
# stay visible without flooding the PR list. Minor/patch updates
|
||||
# still get individual PRs.
|
||||
major-updates:
|
||||
applies-to: version-updates
|
||||
update-types:
|
||||
- "major"
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
|
||||
@@ -6,6 +6,13 @@ on:
|
||||
- "master"
|
||||
tags:
|
||||
- "v*"
|
||||
- "nightly/v*"
|
||||
- "beta/v*"
|
||||
# Release workflows push lane tags with GITHUB_TOKEN, and GitHub suppresses
|
||||
# push-triggered runs for those, so release.yml dispatches this workflow at
|
||||
# the new tag ref instead. The tag mapping below keys off github.ref either
|
||||
# way.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -40,7 +47,19 @@ jobs:
|
||||
id: build-version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
|
||||
case "${GITHUB_REF}" in
|
||||
refs/tags/nightly/v*)
|
||||
# Lane tags carry the exact published version; stamp it verbatim
|
||||
# instead of describing drift from the nearest stable tag.
|
||||
version="${GITHUB_REF#refs/tags/nightly/v}"
|
||||
;;
|
||||
refs/tags/beta/v*)
|
||||
version="${GITHUB_REF#refs/tags/beta/v}"
|
||||
;;
|
||||
*)
|
||||
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
|
||||
;;
|
||||
esac
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "Stamping build version: ${version:-<none>}"
|
||||
|
||||
@@ -131,15 +150,21 @@ jobs:
|
||||
echo "last=${last}" >> "$GITHUB_OUTPUT"
|
||||
echo "count=${count}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Lane tag mapping: master pushes publish `:canary`, nightly/v* tags
|
||||
# publish `:nightly`, and only stable v* tags move `:latest` and the
|
||||
# versioned tags. `:sha-<short>` is published on every build.
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
|
||||
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=sha
|
||||
labels: |
|
||||
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
|
||||
@@ -197,7 +222,19 @@ jobs:
|
||||
id: build-version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
|
||||
case "${GITHUB_REF}" in
|
||||
refs/tags/nightly/v*)
|
||||
# Lane tags carry the exact published version; stamp it verbatim
|
||||
# instead of describing drift from the nearest stable tag.
|
||||
version="${GITHUB_REF#refs/tags/nightly/v}"
|
||||
;;
|
||||
refs/tags/beta/v*)
|
||||
version="${GITHUB_REF#refs/tags/beta/v}"
|
||||
;;
|
||||
*)
|
||||
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
|
||||
;;
|
||||
esac
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "Stamping build version: ${version:-<none>}"
|
||||
|
||||
@@ -288,8 +325,9 @@ jobs:
|
||||
echo "last=${last}" >> "$GITHUB_OUTPUT"
|
||||
echo "count=${count}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Published under the same tag set with a `-cloud` suffix
|
||||
# (sha-<short>-cloud, latest-cloud, <version>-cloud).
|
||||
# Published under the same lane tag set as the self-hosted image, with a
|
||||
# `-cloud` suffix (canary-cloud, nightly-cloud, latest-cloud,
|
||||
# <version>-cloud, sha-<short>-cloud).
|
||||
- name: Docker meta (cloud)
|
||||
id: meta-cloud
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -298,9 +336,12 @@ jobs:
|
||||
flavor: |
|
||||
suffix=-cloud,onlatest=true
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
|
||||
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=sha
|
||||
labels: |
|
||||
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
|
||||
|
||||
@@ -174,8 +174,20 @@ jobs:
|
||||
group_label: server (5/5)
|
||||
shard_index: 4
|
||||
shard_count: 5
|
||||
# workspaces-a was the slowest check in the fully-green PR run
|
||||
# 31371439296 (2026-08-10) at 319s, with the ui project's single
|
||||
# vitest invocation accounting for ~224s and the paperclipai CLI
|
||||
# ~37s. Two shards use Vitest's native --shard on each project's
|
||||
# file list (ui: 439 files, cli: 54), bringing each job to roughly
|
||||
# half the suite time (~130s + setup) without a duration manifest.
|
||||
- group: general-workspaces-a
|
||||
group_label: workspaces-a
|
||||
group_label: workspaces-a (1/2)
|
||||
shard_index: 0
|
||||
shard_count: 2
|
||||
- group: general-workspaces-a
|
||||
group_label: workspaces-a (2/2)
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
- group: general-workspaces-b
|
||||
group_label: workspaces-b
|
||||
|
||||
@@ -275,9 +287,11 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# A successful PR run on 2026-08-04 (30876682788) spent 256s in
|
||||
# serialized shard 2/4, making its 305s job the run's slowest check.
|
||||
# Five shards reduce the measured 739s suite total to about 148s per
|
||||
# A successful PR run on 2026-08-17 (32012408876) spent 291s in
|
||||
# serialized shard 1/5 while its siblings ran 170-201s: round-robin
|
||||
# clustered the heavy suites on one runner. Shards are now balanced
|
||||
# by recorded duration (scripts/serialized-shard-durations.json),
|
||||
# which levels the measured 968s suite total to about 194s per
|
||||
# runner before setup overhead.
|
||||
- shard_index: 0
|
||||
shard_count: 5
|
||||
|
||||
@@ -10,6 +10,8 @@ on:
|
||||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- nightly
|
||||
- beta
|
||||
- latest
|
||||
host_port:
|
||||
description: Host port for the Docker smoke container
|
||||
@@ -69,6 +71,7 @@ jobs:
|
||||
HOST_PORT="${{ inputs.host_port }}" \
|
||||
DATA_DIR="$RUNNER_TEMP/release-smoke-data" \
|
||||
PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \
|
||||
SMOKE_READY_TIMEOUT_SECONDS=420 \
|
||||
SMOKE_DETACH=true \
|
||||
SMOKE_METADATA_FILE="$metadata_file" \
|
||||
./scripts/docker-onboard-smoke.sh
|
||||
|
||||
@@ -64,8 +64,16 @@ jobs:
|
||||
group_label: server (3/3)
|
||||
shard_index: 2
|
||||
shard_count: 3
|
||||
# Keep parity with pr.yml: workspaces-a is split with Vitest's
|
||||
# native --shard because the ui project dominates the lane.
|
||||
- group: general-workspaces-a
|
||||
group_label: workspaces-a
|
||||
group_label: workspaces-a (1/2)
|
||||
shard_index: 0
|
||||
shard_count: 2
|
||||
- group: general-workspaces-a
|
||||
group_label: workspaces-a (2/2)
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
- group: general-workspaces-b
|
||||
group_label: workspaces-b
|
||||
|
||||
|
||||
+900
-13
@@ -4,10 +4,22 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
schedule:
|
||||
# Nightly cut at 09:00 UTC, after the workday's merges have settled.
|
||||
- cron: "0 9 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel to publish
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stable
|
||||
- beta
|
||||
- nightly
|
||||
default: stable
|
||||
source_ref:
|
||||
description: Commit SHA, branch, or tag to publish as stable
|
||||
description: (stable) Commit SHA, branch, or tag to publish as stable
|
||||
required: true
|
||||
type: string
|
||||
default: master
|
||||
@@ -15,8 +27,20 @@ on:
|
||||
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
|
||||
required: false
|
||||
type: string
|
||||
source_version:
|
||||
description: For nightly, the explicit canary version to promote (empty selects the newest canary on master). For beta, the explicit nightly version to promote (empty selects the newest nightly on master).
|
||||
required: false
|
||||
type: string
|
||||
candidate_branch:
|
||||
description: (beta) candidate/beta-* branch to build a cherry-picked beta from. Leave empty to promote a nightly. Mutually exclusive with source_version.
|
||||
required: false
|
||||
type: string
|
||||
skip_soak_justification:
|
||||
description: (stable) Written justification for publishing a stable whose source has not soaked as a beta for 3 days. Leave empty for normal releases.
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Preview the stable release without publishing
|
||||
description: Preview the release without publishing
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
@@ -97,15 +121,761 @@ jobs:
|
||||
fi
|
||||
git push origin "refs/tags/${tag}"
|
||||
|
||||
verify_stable:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# ----- Nightly lane -----------------------------------------------------
|
||||
# Once a night (or on a forced nightly dispatch), promote the newest master
|
||||
# commit that already shipped a green canary: smoke-test that exact
|
||||
# published canary first, then republish the same commit under the nightly
|
||||
# identity. The candidate commit already passed release-verify during its
|
||||
# canary publish, so the nightly publish skips re-verification.
|
||||
|
||||
select_nightly:
|
||||
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.channel == 'nightly')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
proceed: ${{ steps.select.outputs.proceed }}
|
||||
sha: ${{ steps.select.outputs.sha }}
|
||||
canary_version: ${{ steps.select.outputs.canary_version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Select nightly candidate
|
||||
id: select
|
||||
env:
|
||||
EXPLICIT_CANARY_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.source_version || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch origin --tags --prune --quiet
|
||||
|
||||
skip() {
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Nightly skipped"
|
||||
echo ""
|
||||
echo "$1"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Nightly skipped: $1"
|
||||
}
|
||||
|
||||
if [ -n "${EXPLICIT_CANARY_VERSION:-}" ]; then
|
||||
tag="canary/v${EXPLICIT_CANARY_VERSION}"
|
||||
sha="$(git rev-list -n 1 "$tag" 2>/dev/null || true)"
|
||||
if [ -z "$sha" ]; then
|
||||
echo "Error: tag $tag does not exist." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Newest canary-tagged commit on master. Canary tags are pushed
|
||||
# only after a successful canary publish, so tag presence is the
|
||||
# green-publish signal. Walk master newest-first and stop at the
|
||||
# first commit that carries a canary tag.
|
||||
sha="$(grep -m1 -F \
|
||||
-f <(git for-each-ref 'refs/tags/canary/v*' --format='%(objectname)') \
|
||||
<(git rev-list origin/master -n 500) || true)"
|
||||
|
||||
if [ -z "$sha" ]; then
|
||||
skip "No canary/v* tag found on the last 500 commits of master."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tag="$(git tag --points-at "$sha" | grep '^canary/v' | sort -V | tail -1)"
|
||||
fi
|
||||
|
||||
canary_version="${tag#canary/v}"
|
||||
|
||||
existing_nightly="$(git tag --points-at "$sha" | grep '^nightly/v' | head -1 || true)"
|
||||
if [ -n "$existing_nightly" ]; then
|
||||
skip "Candidate \`$sha\` (canary \`$canary_version\`) already shipped as \`$existing_nightly\`."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Promotions run the release tooling of the source commit, so the
|
||||
# source must already understand the nightly channel. (Literal match
|
||||
# of release.sh's channel case arm; if that line is reformatted this
|
||||
# fails closed and should be updated alongside it.)
|
||||
if ! git show "${sha}:scripts/release.sh" | grep -qF 'canary|nightly'; then
|
||||
echo "Error: source commit $sha predates nightly release tooling; promote a newer canary." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "canary_version=$canary_version" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Nightly candidate"
|
||||
echo ""
|
||||
echo "- Source SHA: \`$sha\`"
|
||||
echo "- Source canary: \`$canary_version\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Gate the promotion on the release smoke suite, run against the exact
|
||||
# published canary artifact that would become tonight's nightly. Red smoke
|
||||
# means no nightly tonight. Skipped for dry-run dispatches.
|
||||
smoke_nightly:
|
||||
needs: select_nightly
|
||||
if: needs.select_nightly.outputs.proceed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
uses: ./.github/workflows/release-smoke.yml
|
||||
with:
|
||||
paperclip_version: ${{ needs.select_nightly.outputs.canary_version }}
|
||||
artifact_name: nightly-release-smoke
|
||||
|
||||
publish_nightly:
|
||||
needs: [select_nightly, smoke_nightly]
|
||||
# Publish when smoke passed, or when smoke was deliberately skipped by a
|
||||
# dry-run dispatch (the publish itself is a dry-run in that case).
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.select_nightly.outputs.proceed == 'true' &&
|
||||
(needs.smoke_nightly.result == 'success' ||
|
||||
(needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-canary
|
||||
# The workflow-level concurrency group is per event, so a forced dispatch
|
||||
# nightly could otherwise overlap the scheduled one and race it to the
|
||||
# same next -nightly.N version. Serialize actual nightly publishes across
|
||||
# events here; release.sh additionally refuses to double-publish a commit
|
||||
# that already carries a nightly tag.
|
||||
concurrency:
|
||||
group: release-publish-nightly
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.select_nightly.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Restore tracked install-time changes
|
||||
run: git checkout -- pnpm-lock.yaml
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Publish nightly
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: |
|
||||
args=(nightly --skip-verify)
|
||||
if [ "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}" = "true" ]; then
|
||||
args+=(--dry-run)
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
- name: Dump npm debug logs
|
||||
if: failure()
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for f in "$HOME"/.npm/_logs/*.log; do
|
||||
echo "===== $f ====="
|
||||
tail -n 300 "$f" | sed -E \
|
||||
-e 's#((authorization|_authToken|_auth|node_auth_token|npm_token)"?[[:space:]]*[:=][[:space:]]*"?)(Bearer[[:space:]]+)?[^",[:space:]]+#\1***REDACTED***#Ig'
|
||||
done
|
||||
|
||||
- name: Push nightly tag
|
||||
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^nightly/v' | head -1)"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Error: no nightly tag points at HEAD after release." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git push origin "refs/tags/${tag}"; then
|
||||
sha="$(git rev-parse HEAD)"
|
||||
{
|
||||
echo "## Tag push rejected"
|
||||
echo ""
|
||||
echo "The npm publish succeeded, but pushing \`${tag}\` was rejected."
|
||||
echo "This usually means the tagged commit modifies workflow files,"
|
||||
echo "which GITHUB_TOKEN may not reference when creating refs from"
|
||||
echo "dispatch or scheduled runs. Recover with maintainer credentials:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "git tag ${tag} ${sha}"
|
||||
echo "git push origin refs/tags/${tag}"
|
||||
echo "gh workflow run docker.yml --ref refs/tags/${tag}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::error::Tag push rejected; see the job summary for recovery commands." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag
|
||||
# trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN
|
||||
# pushes), so dispatch the image build at the new tag explicitly.
|
||||
- name: Build Docker images for the nightly tag
|
||||
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^nightly/v' | head -1)"
|
||||
{
|
||||
echo "## Nightly published"
|
||||
echo ""
|
||||
echo "- Source SHA: \`${{ needs.select_nightly.outputs.sha }}\`"
|
||||
echo "- Source canary: \`${{ needs.select_nightly.outputs.canary_version }}\`"
|
||||
echo "- Published nightly: \`${tag#nightly/v}\`"
|
||||
echo "- Docker build dispatched at \`${tag}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# ----- Beta lane --------------------------------------------------------
|
||||
# Beta is a manual, human-approved promotion of a nightly. Unlike the
|
||||
# scheduled nightly lane, a beta dispatch is explicit operator intent, so
|
||||
# selection problems fail the run loudly instead of skipping quietly. The
|
||||
# publish runs behind the npm-beta environment, whose required reviewers
|
||||
# are the approval gate.
|
||||
|
||||
select_beta:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'beta'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
sha: ${{ steps.select.outputs.sha }}
|
||||
nightly_version: ${{ steps.select.outputs.nightly_version }}
|
||||
mode: ${{ steps.select.outputs.mode }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Select beta candidate
|
||||
id: select
|
||||
env:
|
||||
EXPLICIT_NIGHTLY_VERSION: ${{ inputs.source_version }}
|
||||
CANDIDATE_BRANCH: ${{ inputs.candidate_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch origin --tags --prune --quiet
|
||||
|
||||
# Candidate mode: build a cherry-picked beta from a short-lived
|
||||
# candidate branch instead of promoting a nightly.
|
||||
if [ -n "${CANDIDATE_BRANCH:-}" ]; then
|
||||
if [ -n "${EXPLICIT_NIGHTLY_VERSION:-}" ]; then
|
||||
echo "Error: candidate_branch and source_version are mutually exclusive." >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$CANDIDATE_BRANCH" in
|
||||
candidate/beta-*) ;;
|
||||
*)
|
||||
echo "Error: candidate branches must be named candidate/beta-<target> (got: $CANDIDATE_BRANCH)." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
git fetch origin "$CANDIDATE_BRANCH" --quiet
|
||||
sha="$(git rev-parse --verify "origin/${CANDIDATE_BRANCH}^{commit}" 2>/dev/null || true)"
|
||||
if [ -z "$sha" ]; then
|
||||
echo "Error: candidate branch $CANDIDATE_BRANCH does not exist on origin." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
existing_beta="$(git tag --points-at "$sha" | grep '^beta/v' | head -1 || true)"
|
||||
if [ -n "$existing_beta" ]; then
|
||||
echo "Error: candidate head $sha already shipped as $existing_beta." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git show "${sha}:scripts/release.sh" | grep -qF -- '--from-candidate'; then
|
||||
echo "Error: candidate head $sha predates candidate-build release tooling; rebase the candidate onto a newer base." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
merge_base="$(git merge-base origin/master "$sha")"
|
||||
echo "mode=candidate" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "nightly_version=" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Beta candidate branch"
|
||||
echo ""
|
||||
echo "- Branch: \`$CANDIDATE_BRANCH\`"
|
||||
echo "- Head: \`$sha\`"
|
||||
echo "- Base (merge-base with master): \`$merge_base\`"
|
||||
echo "- Cherry-picked commits:"
|
||||
echo ""
|
||||
echo '\`\`\`'
|
||||
git log --oneline "${merge_base}..${sha}"
|
||||
echo '\`\`\`'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "mode=promote" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -n "${EXPLICIT_NIGHTLY_VERSION:-}" ]; then
|
||||
tag="nightly/v${EXPLICIT_NIGHTLY_VERSION}"
|
||||
sha="$(git rev-list -n 1 "$tag" 2>/dev/null || true)"
|
||||
if [ -z "$sha" ]; then
|
||||
echo "Error: tag $tag does not exist." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Newest nightly-tagged commit on master.
|
||||
sha="$(grep -m1 -F \
|
||||
-f <(git for-each-ref 'refs/tags/nightly/v*' --format='%(objectname)') \
|
||||
<(git rev-list origin/master -n 2000) || true)"
|
||||
|
||||
if [ -z "$sha" ]; then
|
||||
echo "Error: no nightly/v* tag found on the last 2000 commits of master. Publish a nightly first, or pass source_version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tag="$(git tag --points-at "$sha" | grep '^nightly/v' | sort -V | tail -1)"
|
||||
fi
|
||||
|
||||
nightly_version="${tag#nightly/v}"
|
||||
|
||||
existing_beta="$(git tag --points-at "$sha" | grep '^beta/v' | head -1 || true)"
|
||||
if [ -n "$existing_beta" ]; then
|
||||
echo "Error: candidate $sha (nightly $nightly_version) already shipped as $existing_beta." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Promotions run the release tooling of the source commit, so the
|
||||
# source must already understand the beta channel. (Literal match of
|
||||
# release.sh's channel case arm; if that line is reformatted this
|
||||
# fails closed and should be updated alongside it.)
|
||||
if ! git show "${sha}:scripts/release.sh" | grep -qF 'canary|nightly|beta|stable)'; then
|
||||
echo "Error: source commit $sha predates beta release tooling; promote a newer nightly whose source contains the beta channel." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "nightly_version=$nightly_version" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Beta candidate"
|
||||
echo ""
|
||||
echo "- Source SHA: \`$sha\`"
|
||||
echo "- Source nightly: \`$nightly_version\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Candidate-branch heads are new commits that never went through a canary
|
||||
# or nightly, so they must pass full verification before publishing.
|
||||
# Promoted nightlies were already verified by their canary run and skip it.
|
||||
verify_beta_candidate:
|
||||
needs: select_beta
|
||||
if: needs.select_beta.outputs.mode == 'candidate'
|
||||
uses: ./.github/workflows/release-verify.yml
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
ref: ${{ needs.select_beta.outputs.sha }}
|
||||
|
||||
publish_beta:
|
||||
needs: [select_beta, verify_beta_candidate]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.select_beta.result == 'success' &&
|
||||
(needs.verify_beta_candidate.result == 'success' ||
|
||||
(needs.verify_beta_candidate.result == 'skipped' && needs.select_beta.outputs.mode == 'promote'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-beta
|
||||
# Serialize beta publishes so two dispatches cannot race to the same next
|
||||
# -beta.N version; release.sh additionally refuses to double-publish a
|
||||
# commit that already carries a beta tag.
|
||||
concurrency:
|
||||
group: release-publish-beta
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
actions: write
|
||||
outputs:
|
||||
beta_version: ${{ steps.result.outputs.beta_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.select_beta.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Restore tracked install-time changes
|
||||
run: git checkout -- pnpm-lock.yaml
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Publish beta
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: |
|
||||
args=(beta --skip-verify)
|
||||
if [ "${{ needs.select_beta.outputs.mode }}" = "candidate" ]; then
|
||||
args+=(--from-candidate)
|
||||
fi
|
||||
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
args+=(--dry-run)
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
- name: Dump npm debug logs
|
||||
if: failure()
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for f in "$HOME"/.npm/_logs/*.log; do
|
||||
echo "===== $f ====="
|
||||
tail -n 300 "$f" | sed -E \
|
||||
-e 's#((authorization|_authToken|_auth|node_auth_token|npm_token)"?[[:space:]]*[:=][[:space:]]*"?)(Bearer[[:space:]]+)?[^",[:space:]]+#\1***REDACTED***#Ig'
|
||||
done
|
||||
|
||||
- name: Push beta tag
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^beta/v' | head -1)"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Error: no beta tag points at HEAD after release." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git push origin "refs/tags/${tag}"; then
|
||||
sha="$(git rev-parse HEAD)"
|
||||
{
|
||||
echo "## Tag push rejected"
|
||||
echo ""
|
||||
echo "The npm publish succeeded, but pushing \`${tag}\` was rejected."
|
||||
echo "This usually means the tagged commit modifies workflow files,"
|
||||
echo "which GITHUB_TOKEN may not reference when creating refs from"
|
||||
echo "dispatch or scheduled runs. Recover with maintainer credentials:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "git tag ${tag} ${sha}"
|
||||
echo "git push origin refs/tags/${tag}"
|
||||
echo "gh workflow run docker.yml --ref refs/tags/${tag}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::error::Tag push rejected; see the job summary for recovery commands." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag
|
||||
# trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN
|
||||
# pushes), so dispatch the image build at the new tag explicitly.
|
||||
- name: Build Docker images for the beta tag
|
||||
id: result
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^beta/v' | head -1)"
|
||||
echo "beta_version=${tag#beta/v}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Beta published"
|
||||
echo ""
|
||||
echo "- Source SHA: \`${{ needs.select_beta.outputs.sha }}\`"
|
||||
echo "- Source nightly: \`${{ needs.select_beta.outputs.nightly_version }}\`"
|
||||
echo "- Published beta: \`${tag#beta/v}\`"
|
||||
echo "- Docker build dispatched at \`${tag}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# Draft the eventual stable's release notes the moment the beta exists:
|
||||
# the promoted bits are frozen now, and the 3-day soak is the natural
|
||||
# review window. The draft lands on a machine-owned branch; a human opens
|
||||
# and merges the PR, because a PR created with GITHUB_TOKEN would not
|
||||
# trigger the pr.yml checks a merge requires.
|
||||
draft_stable_notes:
|
||||
needs: [select_beta, publish_beta]
|
||||
if: ${{ !cancelled() && !inputs.dry_run && needs.publish_beta.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Draft stable notes from the published beta
|
||||
env:
|
||||
BETA_VERSION: ${{ needs.publish_beta.outputs.beta_version }}
|
||||
SOURCE_SHA: ${{ needs.select_beta.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin --tags --quiet
|
||||
# A rejected beta tag push (the workflows-permission case) leaves
|
||||
# the tag absent from origin while npm already has the beta;
|
||||
# recreate it locally so drafting does not block on the manual
|
||||
# tag recovery.
|
||||
if ! git rev-parse --verify "refs/tags/beta/v${BETA_VERSION}" >/dev/null 2>&1; then
|
||||
git tag "beta/v${BETA_VERSION}" "${SOURCE_SHA}"
|
||||
fi
|
||||
./scripts/draft-stable-notes.sh "${BETA_VERSION}"
|
||||
|
||||
- name: Push the draft branch
|
||||
env:
|
||||
BETA_VERSION: ${{ needs.publish_beta.outputs.beta_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
branch="release-notes/v${BETA_VERSION}"
|
||||
git checkout -B "$branch"
|
||||
git add "releases/beta/v${BETA_VERSION}.md"
|
||||
git commit -m "docs(release): draft stable notes for beta ${BETA_VERSION}"
|
||||
# Machine-owned branch: force push so a re-run regenerates cleanly.
|
||||
git push -f origin "$branch"
|
||||
{
|
||||
echo "## Stable notes draft pushed"
|
||||
echo ""
|
||||
echo "- Branch: \`${branch}\`"
|
||||
echo "- Open the PR (a human opens it so CI runs):"
|
||||
echo " https://github.com/${GITHUB_REPOSITORY}/compare/master...${branch}?expand=1"
|
||||
echo "- Edit it during the soak. The stable promotion reads"
|
||||
echo " \`releases/beta/v${BETA_VERSION}.md\` from master."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Post-publish verification: run the release smoke suite against the exact
|
||||
# beta version that was just published.
|
||||
#
|
||||
# The condition must carry an explicit status-check function: without one,
|
||||
# GitHub attaches an implicit success(), which evaluates the needs chain
|
||||
# transitively — and publish_beta's chain contains verify_beta_candidate,
|
||||
# which is skipped on every promote-mode beta. The implicit form silently
|
||||
# skipped this job on the first promote-mode beta after the candidate
|
||||
# lane landed.
|
||||
smoke_beta:
|
||||
needs: publish_beta
|
||||
if: ${{ !cancelled() && needs.publish_beta.result == 'success' && !inputs.dry_run }}
|
||||
uses: ./.github/workflows/release-smoke.yml
|
||||
with:
|
||||
paperclip_version: ${{ needs.publish_beta.outputs.beta_version }}
|
||||
artifact_name: beta-release-smoke
|
||||
|
||||
# ----- Stable lane ------------------------------------------------------
|
||||
|
||||
# Stable releases promote a soaked beta. The preflight enforces that the
|
||||
# source commit shipped as a beta at least 3 days ago (measured from the
|
||||
# npm publish time of that beta version), unless a written justification
|
||||
# is provided. Dry runs report soak state without blocking.
|
||||
# Resolves source_ref to an immutable commit exactly once; every downstream
|
||||
# stable job consumes that SHA. Otherwise a branch or movable tag that
|
||||
# advances mid-run could be soak-checked at one commit and verified or
|
||||
# published at another.
|
||||
preflight_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
sha: ${{ steps.soak.outputs.sha }}
|
||||
beta_version: ${{ steps.soak.outputs.beta_version }}
|
||||
stable_version: ${{ steps.notes.outputs.stable_version }}
|
||||
notes_mode: ${{ steps.notes.outputs.notes_mode }}
|
||||
notes_path: ${{ steps.notes.outputs.notes_path }}
|
||||
notes_ref: ${{ steps.notes.outputs.notes_ref }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check beta soak
|
||||
id: soak
|
||||
env:
|
||||
SOURCE_REF: ${{ inputs.source_ref }}
|
||||
JUSTIFICATION: ${{ inputs.skip_soak_justification }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch origin --tags --prune --quiet
|
||||
|
||||
sha="$(git rev-parse --verify "${SOURCE_REF}^{commit}" 2>/dev/null || true)"
|
||||
if [ -z "$sha" ]; then
|
||||
git fetch origin "$SOURCE_REF" --quiet || true
|
||||
sha="$(git rev-parse --verify "FETCH_HEAD^{commit}" 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$sha" ]; then
|
||||
echo "Error: could not resolve source_ref '$SOURCE_REF' to a commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Stable source pinned"
|
||||
echo ""
|
||||
echo "- source_ref: \`${SOURCE_REF}\` -> \`$sha\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
fail_or_justify() {
|
||||
if [ -n "${JUSTIFICATION:-}" ]; then
|
||||
{
|
||||
echo "## Stable soak gate bypassed"
|
||||
echo ""
|
||||
echo "$1"
|
||||
echo ""
|
||||
echo "Justification: ${JUSTIFICATION}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::warning::Soak gate bypassed: $1"
|
||||
return 0
|
||||
fi
|
||||
if [ "${DRY_RUN}" = "true" ]; then
|
||||
echo "::warning::Soak gate would block a real release: $1"
|
||||
{
|
||||
echo "## Stable soak gate (dry run)"
|
||||
echo ""
|
||||
echo "A real release would be blocked: $1"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
echo "Error: $1" >&2
|
||||
echo "Pass skip_soak_justification with a written reason to release anyway." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
beta_tag="$(git tag --points-at "$sha" | grep '^beta/v' | sort -V | tail -1 || true)"
|
||||
if [ -z "$beta_tag" ]; then
|
||||
fail_or_justify "source commit $sha never shipped as a beta (no beta/v* tag)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
beta_version="${beta_tag#beta/v}"
|
||||
echo "beta_version=${beta_version}" >> "$GITHUB_OUTPUT"
|
||||
publish_time="$(npm view "paperclipai@${beta_version}" time --json 2>/dev/null \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const t=JSON.parse(d);process.stdout.write(typeof t === "string" ? t : (t[process.argv[1]] ?? ""))})' "$beta_version" || true)"
|
||||
if [ -z "$publish_time" ]; then
|
||||
fail_or_justify "could not determine the npm publish time of beta ${beta_version}."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
age_seconds="$(node -e 'process.stdout.write(String(Math.floor((Date.now() - Date.parse(process.argv[1])) / 1000)))' "$publish_time")"
|
||||
min_seconds=$((3 * 24 * 60 * 60))
|
||||
age_days="$(node -e 'process.stdout.write((Number(process.argv[1]) / 86400).toFixed(1))' "$age_seconds")"
|
||||
|
||||
if [ "$age_seconds" -lt "$min_seconds" ]; then
|
||||
fail_or_justify "beta ${beta_version} has only soaked ${age_days} days (minimum is 3)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## Stable soak gate passed"
|
||||
echo ""
|
||||
echo "- Source beta: \`${beta_version}\`"
|
||||
echo "- Soak time: ${age_days} days"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The stable notes need not exist inside the promoted source commit:
|
||||
# a promoted beta's notes are drafted on master (releases/beta/v*.md)
|
||||
# at beta-publish time and edited during the soak. Resolve which copy
|
||||
# publish_stable should read, and fail early — before the npm-stable
|
||||
# approval gate — when none exists. Notes inside the source tree
|
||||
# (the candidate fix path) take precedence.
|
||||
- name: Resolve stable release notes
|
||||
id: notes
|
||||
env:
|
||||
SHA: ${{ steps.soak.outputs.sha }}
|
||||
BETA_VERSION: ${{ steps.soak.outputs.beta_version }}
|
||||
STABLE_DATE: ${{ inputs.stable_date }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Pin the master revision the notes were resolved at, so the
|
||||
# publish and canonicalization steps read the same content even
|
||||
# when master advances during the approval delay.
|
||||
echo "notes_ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
args=(stable --print-version)
|
||||
if [ -n "${STABLE_DATE}" ]; then
|
||||
args+=(--date "${STABLE_DATE}")
|
||||
fi
|
||||
version="$(./scripts/release.sh "${args[@]}")"
|
||||
echo "stable_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if git cat-file -e "${SHA}:releases/v${version}.md" 2>/dev/null; then
|
||||
echo "notes_mode=source_tree" >> "$GITHUB_OUTPUT"
|
||||
echo "notes_path=releases/v${version}.md" >> "$GITHUB_OUTPUT"
|
||||
echo "- Stable notes: \`releases/v${version}.md\` at the source commit" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${BETA_VERSION}" ] && [ -f "releases/beta/v${BETA_VERSION}.md" ]; then
|
||||
echo "notes_mode=master_beta" >> "$GITHUB_OUTPUT"
|
||||
echo "notes_path=releases/beta/v${BETA_VERSION}.md" >> "$GITHUB_OUTPUT"
|
||||
echo "- Stable notes: \`releases/beta/v${BETA_VERSION}.md\` on master" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
msg="no release notes found for stable ${version}: neither releases/v${version}.md at the source commit nor releases/beta/v${BETA_VERSION:-<beta>}.md on master. Merge the notes PR from the beta's draft branch (release-notes/v<beta-version>), or add the file to the source ref."
|
||||
if [ "${DRY_RUN}" = "true" ]; then
|
||||
echo "::warning::${msg}"
|
||||
echo "- Stable notes: MISSING (a real release would be blocked)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "notes_mode=missing" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::${msg}"
|
||||
exit 1
|
||||
|
||||
verify_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable'
|
||||
needs: preflight_stable
|
||||
uses: ./.github/workflows/release-verify.yml
|
||||
with:
|
||||
ref: ${{ needs.preflight_stable.outputs.sha }}
|
||||
|
||||
preview_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
|
||||
needs: verify_stable
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run
|
||||
needs: [preflight_stable, verify_stable]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
@@ -116,7 +886,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
ref: ${{ needs.preflight_stable.outputs.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
@@ -146,21 +916,24 @@ jobs:
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
publish_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
|
||||
needs: verify_stable
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run
|
||||
needs: [preflight_stable, verify_stable]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-stable
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
actions: write
|
||||
outputs:
|
||||
stable_version: ${{ steps.tag.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
ref: ${{ needs.preflight_stable.outputs.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
@@ -184,6 +957,22 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# A promoted beta's notes live on master (drafted at beta publish,
|
||||
# edited during the soak), not inside the promoted source commit — a
|
||||
# commit cannot carry a file named for a promotion date that was
|
||||
# unknown when it was created. Materialize them outside the worktree
|
||||
# so release.sh still runs against a clean source checkout.
|
||||
- name: Materialize stable release notes from master
|
||||
if: needs.preflight_stable.outputs.notes_mode == 'master_beta'
|
||||
env:
|
||||
NOTES_REF: ${{ needs.preflight_stable.outputs.notes_ref }}
|
||||
NOTES_PATH: ${{ needs.preflight_stable.outputs.notes_path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin master --quiet
|
||||
git show "${NOTES_REF}:${NOTES_PATH}" > "$RUNNER_TEMP/stable-notes.md"
|
||||
echo "STABLE_NOTES_FILE=$RUNNER_TEMP/stable-notes.md" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Publish stable
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
@@ -192,6 +981,9 @@ jobs:
|
||||
if [ -n "${{ inputs.stable_date }}" ]; then
|
||||
args+=(--date "${{ inputs.stable_date }}")
|
||||
fi
|
||||
if [ -n "${STABLE_NOTES_FILE:-}" ]; then
|
||||
args+=(--notes-file "$STABLE_NOTES_FILE")
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
- name: Dump npm debug logs
|
||||
@@ -205,13 +997,53 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Push stable tag
|
||||
id: tag
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Error: no stable tag points at HEAD after release." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "refs/tags/${tag}"
|
||||
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
if ! git push origin "refs/tags/${tag}"; then
|
||||
sha="$(git rev-parse HEAD)"
|
||||
{
|
||||
echo "## Tag push rejected"
|
||||
echo ""
|
||||
echo "The npm publish succeeded, but pushing \`${tag}\` was rejected."
|
||||
echo "This usually means the tagged commit modifies workflow files,"
|
||||
echo "which GITHUB_TOKEN may not reference when creating refs from"
|
||||
echo "dispatch or scheduled runs. Recover with maintainer credentials:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "git tag ${tag} ${sha}"
|
||||
echo "git push origin refs/tags/${tag}"
|
||||
echo "gh workflow run docker.yml --ref refs/tags/${tag}"
|
||||
echo "./scripts/create-github-release.sh ${tag#v}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::error::Tag push rejected; see the job summary for recovery commands." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag
|
||||
# trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN
|
||||
# pushes), so dispatch the image build at the new tag explicitly. This
|
||||
# is what moves Docker `:latest` and publishes the versioned stable
|
||||
# image tags.
|
||||
- name: Build Docker images for the stable tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
|
||||
if gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"; then
|
||||
echo "Dispatched docker.yml at ${tag}."
|
||||
else
|
||||
# Older source commits may predate docker.yml's workflow_dispatch
|
||||
# trigger; the dispatch then fails while the npm release is
|
||||
# already complete and correct.
|
||||
echo "::warning::Could not dispatch docker.yml at ${tag}. Run docker.yml manually at that tag to publish the stable images."
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
@@ -223,4 +1055,59 @@ jobs:
|
||||
echo "Error: no v* tag points at HEAD after stable release." >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/create-github-release.sh "$version"
|
||||
args=("$version")
|
||||
if [ -n "${STABLE_NOTES_FILE:-}" ]; then
|
||||
args+=(--notes-file "$STABLE_NOTES_FILE")
|
||||
fi
|
||||
./scripts/create-github-release.sh "${args[@]}"
|
||||
|
||||
# After a stable ships from master-side beta notes, move the file to its
|
||||
# canonical home (releases/vYYYY.MDD.P.md) so the stable-notes invariant
|
||||
# holds durably. Machine-owned branch + human-opened PR, as with the
|
||||
# draft job above.
|
||||
canonicalize_stable_notes:
|
||||
needs: [preflight_stable, publish_stable]
|
||||
if: ${{ needs.preflight_stable.outputs.notes_mode == 'master_beta' && needs.publish_stable.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Push the canonicalization branch
|
||||
env:
|
||||
STABLE_VERSION: ${{ needs.publish_stable.outputs.stable_version }}
|
||||
NOTES_PATH: ${{ needs.preflight_stable.outputs.notes_path }}
|
||||
NOTES_REF: ${{ needs.preflight_stable.outputs.notes_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
branch="release-notes/v${STABLE_VERSION}-canonicalize"
|
||||
git checkout -B "$branch"
|
||||
# Canonicalize exactly what shipped: take the notes at the pinned
|
||||
# revision the release read them from, so a master edit made
|
||||
# during the run surfaces as a reviewable diff in this PR instead
|
||||
# of silently diverging from the published GitHub Release.
|
||||
git show "${NOTES_REF}:${NOTES_PATH}" > "releases/v${STABLE_VERSION}.md"
|
||||
git add "releases/v${STABLE_VERSION}.md"
|
||||
if git ls-files --error-unmatch "${NOTES_PATH}" >/dev/null 2>&1; then
|
||||
git rm -q "${NOTES_PATH}"
|
||||
fi
|
||||
git commit -m "docs(release): canonicalize stable notes for v${STABLE_VERSION}"
|
||||
git push -f origin "$branch"
|
||||
{
|
||||
echo "## Stable notes canonicalization pushed"
|
||||
echo ""
|
||||
echo "- Branch: \`${branch}\`"
|
||||
echo "- Open and merge the PR (a human opens it so CI runs):"
|
||||
echo " https://github.com/${GITHUB_REPOSITORY}/compare/master...${branch}?expand=1"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -61,6 +61,7 @@ tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
tests/release-smoke/test-results/
|
||||
tests/release-smoke/playwright-report/
|
||||
test-results/issue-detail-perf/
|
||||
tests/storybook-visual/.cache/
|
||||
tests/storybook-visual/.snapshots/
|
||||
tests/storybook-visual/baseline-review/
|
||||
|
||||
@@ -25,6 +25,7 @@ COPY packages/google-sheets-mcp-server/package.json packages/google-sheets-mcp-s
|
||||
COPY packages/kv-demo-mcp-server/package.json packages/kv-demo-mcp-server/
|
||||
COPY packages/mcp-server/package.json packages/mcp-server/
|
||||
COPY packages/skills-catalog/package.json packages/skills-catalog/
|
||||
COPY packages/tailscale-https-broker/package.json packages/tailscale-https-broker/
|
||||
COPY packages/teams-catalog/package.json packages/teams-catalog/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
channelForVersion,
|
||||
collectChannelState,
|
||||
RELEASE_CHANNELS,
|
||||
} from "../commands/channels.js";
|
||||
import type { CommandRunner } from "../commands/install.js";
|
||||
|
||||
describe("channelForVersion", () => {
|
||||
it("maps published versions to their lane", () => {
|
||||
expect(channelForVersion("2026.811.0")).toBe("stable");
|
||||
expect(channelForVersion("2026.811.0-beta.0")).toBe("beta");
|
||||
expect(channelForVersion("2026.811.0-nightly.0")).toBe("nightly");
|
||||
expect(channelForVersion("2026.811.0-canary.3")).toBe("canary");
|
||||
});
|
||||
|
||||
it("treats non-CalVer versions as unknown instead of guessing", () => {
|
||||
expect(channelForVersion("0.3.1")).toBe("unknown");
|
||||
expect(channelForVersion("2026.811.0-rc.1")).toBe("unknown");
|
||||
expect(channelForVersion("garbage")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectChannelState", () => {
|
||||
const versionsByTag: Record<string, string> = {
|
||||
latest: "2026.722.0",
|
||||
beta: "2026.811.0-beta.0",
|
||||
nightly: "2026.811.0-nightly.0",
|
||||
canary: "2026.811.0-canary.3",
|
||||
};
|
||||
|
||||
const fakeRunner: CommandRunner = async (_command, args) => {
|
||||
const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@"));
|
||||
const tag = spec?.slice("paperclipai@".length) ?? "";
|
||||
const version = versionsByTag[tag];
|
||||
if (!version) throw new Error(`unexpected dist-tag: ${tag}`);
|
||||
return { stdout: JSON.stringify(version), stderr: "" };
|
||||
};
|
||||
|
||||
it("resolves every channel's dist-tag from the registry", async () => {
|
||||
const state = await collectChannelState(fakeRunner);
|
||||
|
||||
expect(state.map((entry) => entry.channel)).toEqual([
|
||||
"stable",
|
||||
"beta",
|
||||
"nightly",
|
||||
"canary",
|
||||
]);
|
||||
expect(state.map((entry) => entry.version)).toEqual([
|
||||
"2026.722.0",
|
||||
"2026.811.0-beta.0",
|
||||
"2026.811.0-nightly.0",
|
||||
"2026.811.0-canary.3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("degrades a single unavailable channel to null without failing the rest", async () => {
|
||||
const flakyRunner: CommandRunner = async (command, args, options) => {
|
||||
const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@"));
|
||||
if (spec === "paperclipai@nightly") throw new Error("registry timeout");
|
||||
return fakeRunner(command, args, options);
|
||||
};
|
||||
|
||||
const state = await collectChannelState(flakyRunner);
|
||||
const byChannel = Object.fromEntries(state.map((entry) => [entry.channel, entry.version]));
|
||||
|
||||
expect(byChannel.nightly).toBeNull();
|
||||
expect(byChannel.stable).toBe("2026.722.0");
|
||||
expect(byChannel.beta).toBe("2026.811.0-beta.0");
|
||||
expect(byChannel.canary).toBe("2026.811.0-canary.3");
|
||||
});
|
||||
|
||||
it("keeps the channel table and dist-tags in sync", () => {
|
||||
expect(RELEASE_CHANNELS.map((entry) => entry.distTag)).toEqual([
|
||||
"latest",
|
||||
"beta",
|
||||
"nightly",
|
||||
"canary",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,494 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readZipArchive } from "@paperclipai/shared/portability-zip";
|
||||
import {
|
||||
CHUNKED_IMPORT_THRESHOLD_BYTES,
|
||||
IMPORT_TRANSFER_PART_SIZE_BYTES,
|
||||
buildImportTransferManifest,
|
||||
registerCompanyCommands,
|
||||
EXISTING_COMPANY_CHUNKED_IMPORT_THRESHOLD_BYTES,
|
||||
resolveChunkedImportZip,
|
||||
uploadCompanyImportTransfer,
|
||||
} from "../commands/client/company.js";
|
||||
import { createStoredZipArchive } from "./helpers/zip.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-transfer-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function sha256Hex(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
/** Two-part fixture: one full 32 MB part plus a short tail. */
|
||||
function buildTwoPartZipBytes(): Uint8Array {
|
||||
const bytes = new Uint8Array(IMPORT_TRANSFER_PART_SIZE_BYTES + 3);
|
||||
bytes.set([1, 2, 3], IMPORT_TRANSFER_PART_SIZE_BYTES);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("buildImportTransferManifest", () => {
|
||||
it("declares the whole zip and its 32 MB byte-range parts with content hashes", () => {
|
||||
const zipBytes = buildTwoPartZipBytes();
|
||||
const manifest = buildImportTransferManifest(zipBytes);
|
||||
|
||||
expect(manifest.totalBytes).toBe(zipBytes.length);
|
||||
expect(manifest.partSizeBytes).toBe(IMPORT_TRANSFER_PART_SIZE_BYTES);
|
||||
expect(manifest.zipSha256).toBe(sha256Hex(zipBytes));
|
||||
expect(manifest.parts).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
byteSize: IMPORT_TRANSFER_PART_SIZE_BYTES,
|
||||
sha256: sha256Hex(zipBytes.subarray(0, IMPORT_TRANSFER_PART_SIZE_BYTES)),
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
byteSize: 3,
|
||||
sha256: sha256Hex(zipBytes.subarray(IMPORT_TRANSFER_PART_SIZE_BYTES)),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal single-entry DEFLATE zip, byte-compatible with the shared reader —
|
||||
// the stored-zip helper cannot model a small-compressed/large-inflated entry.
|
||||
function buildDeflateZip(entryPath: string, text: string): Uint8Array {
|
||||
const raw = Buffer.from(text, "utf8");
|
||||
const body = deflateRawSync(raw);
|
||||
const name = Buffer.from(entryPath, "utf8");
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of raw) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||
}
|
||||
crc = (crc ^ 0xffffffff) >>> 0;
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(body.length, 20);
|
||||
central.writeUInt32LE(raw.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(0, 42);
|
||||
name.copy(central, 46);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(1, 8);
|
||||
eocd.writeUInt16LE(1, 10);
|
||||
eocd.writeUInt32LE(central.length, 12);
|
||||
eocd.writeUInt32LE(local.length + body.length, 16);
|
||||
return new Uint8Array(Buffer.concat([local, body, central, eocd]));
|
||||
}
|
||||
|
||||
describe("resolveChunkedImportZip", () => {
|
||||
|
||||
it("returns null for a zip at or under the threshold", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const zipPath = path.join(dir, "small.zip");
|
||||
await writeFile(zipPath, Buffer.alloc(1024));
|
||||
|
||||
expect(await resolveChunkedImportZip(zipPath)).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the chunked path for a small zip whose entries inflate past the threshold", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const zipPath = path.join(dir, "dense-package.zip");
|
||||
// ~64 MB of repetitive text DEFLATEs to a tiny file: far under the raw
|
||||
// 48 MB threshold, but the inline body would carry the inflated entries,
|
||||
// so the estimated request size sends the zip down the chunked path.
|
||||
const zipBytes = buildDeflateZip("dense-package/NOTES.md", "paperclip agent docs\n".repeat(3_200_000));
|
||||
await writeFile(zipPath, zipBytes);
|
||||
|
||||
const resolved = await resolveChunkedImportZip(zipPath);
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.rootPath).toBe("dense-package");
|
||||
expect(sha256Hex(resolved!.zipBytes)).toBe(sha256Hex(zipBytes));
|
||||
});
|
||||
|
||||
it("uses the lower existing-company threshold for the chunk decision", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const packageDir = path.join(dir, "midsize-package");
|
||||
await mkdir(path.join(packageDir, "blobs"), { recursive: true });
|
||||
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
|
||||
// ~9 MB of blob bytes: estimated inline ~12 MB — fine for the generic
|
||||
// import path (64 MB parser) but over the existing-company path's
|
||||
// default 10 MB parser, so only the existing-target threshold chunks it.
|
||||
await writeFile(path.join(packageDir, "blobs", "1a2b3c4d"), Buffer.alloc(9 * 1024 * 1024, 5));
|
||||
|
||||
expect(await resolveChunkedImportZip(packageDir)).toBeNull();
|
||||
const chunked = await resolveChunkedImportZip(
|
||||
packageDir,
|
||||
EXISTING_COMPANY_CHUNKED_IMPORT_THRESHOLD_BYTES,
|
||||
);
|
||||
expect(chunked).not.toBeNull();
|
||||
expect(chunked!.rootPath).toBe("midsize-package");
|
||||
});
|
||||
|
||||
it("keeps a small zip inline when its entries stay under the estimated threshold", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const zipPath = path.join(dir, "modest-package.zip");
|
||||
await writeFile(zipPath, buildDeflateZip("modest-package/COMPANY.md", "# Company\n"));
|
||||
|
||||
expect(await resolveChunkedImportZip(zipPath)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads an oversized zip file as-is so its declared hashes match the file on disk", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const zipPath = path.join(dir, "big-package.zip");
|
||||
const zipBytes = Buffer.alloc(CHUNKED_IMPORT_THRESHOLD_BYTES + 1024, 7);
|
||||
await writeFile(zipPath, zipBytes);
|
||||
|
||||
const resolved = await resolveChunkedImportZip(zipPath);
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.rootPath).toBe("big-package");
|
||||
expect(resolved!.zipBytes.length).toBe(zipBytes.length);
|
||||
expect(sha256Hex(resolved!.zipBytes)).toBe(sha256Hex(zipBytes));
|
||||
});
|
||||
|
||||
it("returns null for a folder whose portable content is under the threshold", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const packageDir = path.join(dir, "small-package");
|
||||
await mkdir(packageDir, { recursive: true });
|
||||
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
|
||||
|
||||
expect(await resolveChunkedImportZip(packageDir)).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the chunked path for a binary-heavy folder whose inline body outgrows the threshold", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const packageDir = path.join(dir, "binary-package");
|
||||
await mkdir(path.join(packageDir, "blobs"), { recursive: true });
|
||||
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
|
||||
// 40 MB of raw blob bytes: under the 48 MB raw threshold, but the inline
|
||||
// JSON body would carry them base64-inflated (~53 MB) — past the
|
||||
// threshold on the estimated request size, so the zip travels chunked.
|
||||
await writeFile(
|
||||
path.join(packageDir, "blobs", "9a1b2c3d"),
|
||||
Buffer.alloc(40 * 1024 * 1024, 3),
|
||||
);
|
||||
|
||||
const resolved = await resolveChunkedImportZip(packageDir);
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.rootPath).toBe("binary-package");
|
||||
const archive = await readZipArchive(resolved!.zipBytes);
|
||||
expect(Object.keys(archive.files).sort()).toEqual(["COMPANY.md", "blobs/9a1b2c3d"]);
|
||||
});
|
||||
|
||||
it("keeps a text folder under both the raw and estimated measures inline", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const packageDir = path.join(dir, "text-package");
|
||||
await mkdir(packageDir, { recursive: true });
|
||||
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
|
||||
// Sizable but nowhere near the threshold on either measure: text entries
|
||||
// travel JSON-escaped, close to their raw size, so no base64 inflation
|
||||
// pushes this folder onto the chunked path.
|
||||
await writeFile(path.join(packageDir, "NOTES.md"), "agent docs line\n".repeat(200_000));
|
||||
|
||||
expect(await resolveChunkedImportZip(packageDir)).toBeNull();
|
||||
});
|
||||
|
||||
it("zips an oversized folder in memory with the same walk filters as the inline path", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const packageDir = path.join(dir, "big-package");
|
||||
await mkdir(path.join(packageDir, "blobs"), { recursive: true });
|
||||
await mkdir(path.join(packageDir, ".git"), { recursive: true });
|
||||
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
|
||||
await writeFile(path.join(packageDir, "notes.txt"), "not portable\n");
|
||||
await writeFile(path.join(packageDir, ".git", "HEAD"), "ref: refs/heads/main\n");
|
||||
await writeFile(
|
||||
path.join(packageDir, "blobs", "4f2d1c9a"),
|
||||
Buffer.alloc(CHUNKED_IMPORT_THRESHOLD_BYTES + 1024, 9),
|
||||
);
|
||||
|
||||
const resolved = await resolveChunkedImportZip(packageDir);
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.rootPath).toBe("big-package");
|
||||
|
||||
// The archive unzips back into the same bundle the inline source carries.
|
||||
const archive = await readZipArchive(resolved!.zipBytes);
|
||||
expect(archive.rootPath).toBe("big-package");
|
||||
expect(Object.keys(archive.files).sort()).toEqual(["COMPANY.md", "blobs/4f2d1c9a"]);
|
||||
expect(archive.files["COMPANY.md"]).toBe("# Company\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadCompanyImportTransfer", () => {
|
||||
const zipBytes = buildTwoPartZipBytes();
|
||||
type TransferApi = Parameters<typeof uploadCompanyImportTransfer>[0];
|
||||
|
||||
function fakeApi(overrides: { post?: ReturnType<typeof vi.fn>; putRaw?: ReturnType<typeof vi.fn> } = {}) {
|
||||
const post = overrides.post
|
||||
?? vi.fn().mockResolvedValue({
|
||||
transferId: "transfer-1",
|
||||
status: "running",
|
||||
alreadyCompleted: false,
|
||||
totalParts: 2,
|
||||
missingParts: [0, 1],
|
||||
});
|
||||
const putRaw = overrides.putRaw ?? vi.fn().mockResolvedValue({ ok: true });
|
||||
return { api: { post, putRaw } as unknown as TransferApi, post, putRaw };
|
||||
}
|
||||
|
||||
it("uploads only the parts the server reports missing", async () => {
|
||||
const { api, post, putRaw } = fakeApi({
|
||||
post: vi.fn().mockResolvedValue({
|
||||
transferId: "transfer-1",
|
||||
status: "running",
|
||||
alreadyCompleted: false,
|
||||
totalParts: 2,
|
||||
missingParts: [1],
|
||||
}),
|
||||
});
|
||||
const progress: number[] = [];
|
||||
|
||||
const transferId = await uploadCompanyImportTransfer(api, zipBytes, {
|
||||
onProgress: (update) => progress.push(update.uploadedParts),
|
||||
});
|
||||
|
||||
expect(transferId).toBe("transfer-1");
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
"/api/companies/import/transfers",
|
||||
expect.objectContaining({ totalBytes: zipBytes.length }),
|
||||
);
|
||||
expect(putRaw).toHaveBeenCalledTimes(1);
|
||||
expect(putRaw.mock.calls[0]![0]).toBe("/api/companies/import/transfers/transfer-1/parts/1");
|
||||
expect(putRaw.mock.calls[0]![1]).toHaveLength(3);
|
||||
expect(progress).toEqual([2]);
|
||||
});
|
||||
|
||||
it("retries a failed part before succeeding", async () => {
|
||||
const putRaw = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("socket hang up"))
|
||||
.mockRejectedValueOnce(new Error("socket hang up"))
|
||||
.mockResolvedValue({ ok: true });
|
||||
const { api } = fakeApi({ putRaw });
|
||||
|
||||
await expect(uploadCompanyImportTransfer(api, zipBytes)).resolves.toBe("transfer-1");
|
||||
// Part 0 took three attempts; part 1 succeeded first try.
|
||||
expect(putRaw).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("surfaces the upload error after exhausting the per-part attempts", async () => {
|
||||
const putRaw = vi.fn().mockRejectedValue(new Error("socket hang up"));
|
||||
const { api } = fakeApi({ putRaw });
|
||||
|
||||
await expect(uploadCompanyImportTransfer(api, zipBytes)).rejects.toThrow("socket hang up");
|
||||
expect(putRaw).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("refuses a transfer whose content was already applied", async () => {
|
||||
const { api, putRaw } = fakeApi({
|
||||
post: vi.fn().mockResolvedValue({
|
||||
transferId: "transfer-1",
|
||||
status: "completed",
|
||||
alreadyCompleted: true,
|
||||
totalParts: 2,
|
||||
missingParts: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(uploadCompanyImportTransfer(api, zipBytes)).rejects.toThrow(/already imported/);
|
||||
expect(putRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("company import command over the chunked transfer path", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_COMPANY_ID;
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function runCommand(args: string[]): Promise<void> {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => undefined,
|
||||
writeErr: () => undefined,
|
||||
});
|
||||
registerCompanyCommands(program);
|
||||
await program.parseAsync(args, { from: "user" });
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function minimalPreview() {
|
||||
return {
|
||||
include: { company: true, agents: true, projects: true, issues: true },
|
||||
targetCompanyId: null,
|
||||
targetCompanyName: null,
|
||||
collisionStrategy: "rename",
|
||||
selectedAgentSlugs: [],
|
||||
plan: { companyAction: "create", agentPlans: [], projectPlans: [], issuePlans: [] },
|
||||
manifest: { agents: [], projects: [], issues: [], skills: [], company: null },
|
||||
files: {},
|
||||
envInputs: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("slices an oversized local zip into a transfer and applies it against the spool", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const zipPath = path.join(dir, "big-package.zip");
|
||||
await writeFile(zipPath, Buffer.alloc(CHUNKED_IMPORT_THRESHOLD_BYTES + 1024, 5));
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
transferId: "transfer-1",
|
||||
status: "running",
|
||||
alreadyCompleted: false,
|
||||
totalParts: 2,
|
||||
missingParts: [0, 1],
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true, index: 0, alreadyCompleted: false }))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true, index: 1, alreadyCompleted: false }))
|
||||
.mockResolvedValueOnce(jsonResponse(minimalPreview()))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
company: { id: "company-9", name: "Imported", action: "created" },
|
||||
agents: [],
|
||||
skills: [],
|
||||
projects: [],
|
||||
routines: [],
|
||||
envInputs: [],
|
||||
warnings: [],
|
||||
}));
|
||||
|
||||
await runCommand([
|
||||
"company",
|
||||
"import",
|
||||
zipPath,
|
||||
"--target",
|
||||
"new",
|
||||
"--yes",
|
||||
"--json",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"board-token",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://paperclip.test/api/companies/import/transfers",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
const declared = JSON.parse(String(fetchMock.mock.calls[0]![1].body));
|
||||
expect(declared.totalBytes).toBe(CHUNKED_IMPORT_THRESHOLD_BYTES + 1024);
|
||||
expect(declared.parts).toHaveLength(2);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/companies/import/transfers/transfer-1/parts/0",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ "content-type": "application/octet-stream" }),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"http://paperclip.test/api/companies/import/transfers/transfer-1/parts/1",
|
||||
expect.objectContaining({ method: "PUT" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"http://paperclip.test/api/companies/import/transfers/transfer-1/preview",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
// Preview and apply carry the meta fields, never an inline source.
|
||||
const previewBody = JSON.parse(String(fetchMock.mock.calls[3]![1].body));
|
||||
expect(previewBody.target).toEqual({ mode: "new_company", newCompanyName: null });
|
||||
expect(previewBody).not.toHaveProperty("source");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
"http://paperclip.test/api/companies/import/transfers/transfer-1/apply",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
const applyBody = JSON.parse(String(fetchMock.mock.calls[4]![1].body));
|
||||
expect(applyBody).not.toHaveProperty("source");
|
||||
|
||||
expect(JSON.parse(String(logSpy.mock.calls.at(-1)?.[0]))).toMatchObject({
|
||||
company: { id: "company-9" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps small local zips on the inline single-shot path", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const zipPath = path.join(dir, "small-package.zip");
|
||||
await writeFile(zipPath, createStoredZipArchive({ "COMPANY.md": "# Company\n" }, "small-package"));
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(minimalPreview()));
|
||||
|
||||
await runCommand([
|
||||
"company",
|
||||
"import",
|
||||
zipPath,
|
||||
"--target",
|
||||
"new",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"board-token",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://paperclip.test/api/companies/import/preview",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]![1].body));
|
||||
expect(body.source.type).toBe("inline");
|
||||
expect(body.source.files["COMPANY.md"]).toBe("# Company\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { databaseCheck } from "../checks/database-check.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const created: string[] = [];
|
||||
const ORIGINAL_IN_WORKTREE = process.env.PAPERCLIP_IN_WORKTREE;
|
||||
|
||||
function makeBase(): string {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dbcheck-"));
|
||||
created.push(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
function embeddedConfig(dataDir: string): PaperclipConfig {
|
||||
return {
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: dataDir,
|
||||
embeddedPostgresPort: 54321,
|
||||
},
|
||||
} as unknown as PaperclipConfig;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (ORIGINAL_IN_WORKTREE === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
|
||||
else process.env.PAPERCLIP_IN_WORKTREE = ORIGINAL_IN_WORKTREE;
|
||||
while (created.length > 0) {
|
||||
const dir = created.pop();
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("databaseCheck — embedded postgres temp-dir guard", () => {
|
||||
it("passes when the data dir is on persistent (non-temp) storage", async () => {
|
||||
const base = makeBase();
|
||||
// Treat a sibling dir as the OS temp root so the persistent dir is outside it.
|
||||
vi.spyOn(os, "tmpdir").mockReturnValue(path.join(base, "fake-tmp"));
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
const persistentDataDir = path.join(base, "persistent", "instances", "default", "db");
|
||||
|
||||
const result = await databaseCheck(embeddedConfig(persistentDataDir), path.join(base, "config.json"));
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.message).toContain("Embedded PostgreSQL configured at");
|
||||
});
|
||||
|
||||
it("warns when a worktree-mode data dir lives inside the OS temp directory", async () => {
|
||||
const base = makeBase();
|
||||
const fakeTmp = path.join(base, "fake-tmp");
|
||||
vi.spyOn(os, "tmpdir").mockReturnValue(fakeTmp);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
const tmpDataDir = path.join(fakeTmp, "instances", "default", "db");
|
||||
|
||||
const result = await databaseCheck(embeddedConfig(tmpDataDir), path.join(base, "config.json"));
|
||||
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.message).toMatch(/temp directory/i);
|
||||
expect(result.message).toMatch(/ephemeral/i);
|
||||
expect(result.repairHint).toContain("PAPERCLIP_HOME");
|
||||
// Must warn BEFORE creating anything — don't bootstrap the throwaway temp dir.
|
||||
expect(fs.existsSync(tmpDataDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT warn for a temp data dir when not in worktree mode (intentional ephemeral/CI use)", async () => {
|
||||
const base = makeBase();
|
||||
const fakeTmp = path.join(base, "fake-tmp");
|
||||
vi.spyOn(os, "tmpdir").mockReturnValue(fakeTmp);
|
||||
delete process.env.PAPERCLIP_IN_WORKTREE;
|
||||
const tmpDataDir = path.join(fakeTmp, "instances", "default", "db");
|
||||
|
||||
const result = await databaseCheck(embeddedConfig(tmpDataDir), path.join(base, "config.json"));
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectEnvLabDoctorStatus, resolveEnvLabSshStatePath } from "../commands/env-lab.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as p from "@clack/prompts";
|
||||
import {
|
||||
buildEnvLabCleanupCommand,
|
||||
collectEnvLabDoctorStatus,
|
||||
envLabDoctorCommand,
|
||||
resolveEnvLabCliInvocation,
|
||||
resolveEnvLabSshStatePath,
|
||||
} from "../commands/env-lab.js";
|
||||
|
||||
describe("env-lab command", () => {
|
||||
it("resolves the default SSH fixture state path under the instance root", () => {
|
||||
@@ -22,3 +29,259 @@ describe("env-lab command", () => {
|
||||
expect(status.ssh.environment).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("env-lab cleanup command hint", () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
});
|
||||
|
||||
// Resolve a source-checkout invocation for a fabricated checkout root. A source
|
||||
// checkout runs this module from `<root>/src/commands/env-lab.ts`, so the
|
||||
// resolver reads that layout and returns the tsx runner and source entry.
|
||||
function sourceInvocation(root: string) {
|
||||
return resolveEnvLabCliInvocation(path.join(root, "src", "commands", "env-lab.ts"));
|
||||
}
|
||||
|
||||
// Resolve a bundled-build invocation for a fabricated package root. The bundled
|
||||
// build runs this module from `<root>/dist/index.js`, so the resolver returns
|
||||
// that file as the entry with no tsx runner.
|
||||
function bundledInvocation(root: string) {
|
||||
return resolveEnvLabCliInvocation(path.join(root, "dist", "index.js"));
|
||||
}
|
||||
|
||||
// Split a command that uses POSIX single-quoting into its argument tokens. The
|
||||
// parser reads a single-quoted span verbatim and reads `\'` outside a span as a
|
||||
// literal single quote. This is the same rule a POSIX shell obeys, so a token
|
||||
// list proves the shell reads the exact paths and runs no embedded command.
|
||||
function tokenizePosix(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let started = false;
|
||||
let inQuotes = false;
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const character = command[index];
|
||||
if (inQuotes) {
|
||||
if (character === "'") {
|
||||
inQuotes = false;
|
||||
} else {
|
||||
current += character;
|
||||
}
|
||||
} else if (character === "'") {
|
||||
inQuotes = true;
|
||||
started = true;
|
||||
} else if (character === "\\") {
|
||||
index += 1;
|
||||
current += command[index];
|
||||
started = true;
|
||||
} else if (character === " ") {
|
||||
if (started) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
started = false;
|
||||
}
|
||||
} else {
|
||||
current += character;
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
if (started) {
|
||||
tokens.push(current);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Return the two path arguments from the `node` command.
|
||||
function extractPaths(command: string): string[] {
|
||||
const tokens = tokenizePosix(command);
|
||||
return tokens.slice(1, 3);
|
||||
}
|
||||
|
||||
it("resolves both CLI paths to absolute paths", () => {
|
||||
const command = buildEnvLabCleanupCommand();
|
||||
const paths = extractPaths(command);
|
||||
|
||||
expect(paths).toHaveLength(2);
|
||||
for (const resolved of paths) {
|
||||
expect(path.isAbsolute(resolved)).toBe(true);
|
||||
}
|
||||
expect(command.endsWith("env-lab down")).toBe(true);
|
||||
});
|
||||
|
||||
it("points at the checked-out tsx runner and cli source entry", () => {
|
||||
const [tsxBin, entry] = extractPaths(buildEnvLabCleanupCommand());
|
||||
|
||||
expect(tsxBin).toContain(
|
||||
path.join("cli", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
);
|
||||
expect(entry).toContain(path.join("cli", "src", "index.ts"));
|
||||
});
|
||||
|
||||
it("returns the same command from a checkout subdirectory", () => {
|
||||
const fromRoot = buildEnvLabCleanupCommand();
|
||||
|
||||
// Simulate a contributor who runs `env-lab doctor` from a subdirectory of
|
||||
// the checkout. A relative path would change with the working directory, so
|
||||
// this asserts the command stays constant.
|
||||
process.chdir(path.dirname(originalCwd));
|
||||
const fromParent = buildEnvLabCleanupCommand();
|
||||
process.chdir(originalCwd);
|
||||
|
||||
expect(fromParent).toBe(fromRoot);
|
||||
});
|
||||
|
||||
it("never restores the unsafe pnpm invocation forms", () => {
|
||||
const command = buildEnvLabCleanupCommand();
|
||||
|
||||
// The bare `pnpm paperclipai` script form is unsafe. The `pnpm exec` form
|
||||
// does not resolve the CLI binary. Keep both out of the hint.
|
||||
expect(command).not.toContain("pnpm paperclipai");
|
||||
expect(command).not.toContain("pnpm exec paperclipai");
|
||||
});
|
||||
|
||||
// A checkout path can hold shell metacharacters. A contributor copies the hint
|
||||
// and pastes it into a shell. The hint must neutralize each metacharacter, so
|
||||
// the shell reads the exact path and runs no embedded command. Each case below
|
||||
// is a checkout root with one dangerous construct.
|
||||
const dangerousRoots = [
|
||||
{ label: "a dollar sign", root: "/tmp/env$lab/checkout" },
|
||||
{ label: "command substitution", root: "/tmp/$(touch pwned)/checkout" },
|
||||
{ label: "backticks", root: "/tmp/`touch pwned`/checkout" },
|
||||
{ label: "a double quote", root: '/tmp/env"lab/checkout' },
|
||||
{ label: "a single quote", root: "/tmp/env'lab/checkout" },
|
||||
];
|
||||
|
||||
for (const { label, root } of dangerousRoots) {
|
||||
it(`keeps a checkout path with ${label} inert in the cleanup hint`, () => {
|
||||
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation(root) });
|
||||
const tokens = tokenizePosix(command);
|
||||
const tsxBin = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const entry = path.join(root, "src", "index.ts");
|
||||
|
||||
// The shell reads the exact paths as single argument tokens. It does not
|
||||
// split the paths or run the embedded construct.
|
||||
expect(tokens).toEqual(["node", tsxBin, entry, "env-lab", "down"]);
|
||||
|
||||
// The old double-quoted form left `$(...)`, a backtick pair, and `$NAME`
|
||||
// live. Do not restore it.
|
||||
expect(command).not.toContain(`"${tsxBin}"`);
|
||||
expect(command).not.toContain(`"${entry}"`);
|
||||
});
|
||||
}
|
||||
|
||||
it("forwards the inspected instance to the cleanup hint", () => {
|
||||
const command = buildEnvLabCleanupCommand({
|
||||
instance: "fixture-test",
|
||||
invocation: sourceInvocation("/tmp/checkout"),
|
||||
});
|
||||
const tokens = tokenizePosix(command);
|
||||
|
||||
// The hint ends with `--instance <id>`, so it stops the fixture the doctor
|
||||
// command diagnosed, not the default instance.
|
||||
expect(tokens.slice(-2)).toEqual(["--instance", "fixture-test"]);
|
||||
});
|
||||
|
||||
it("omits the instance flag when the doctor command uses the default instance", () => {
|
||||
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation("/tmp/checkout") });
|
||||
|
||||
// Without a selected instance, `env-lab down` resolves the same default
|
||||
// instance the doctor command inspected. Do not add an empty flag.
|
||||
expect(command).not.toContain("--instance");
|
||||
expect(command.endsWith("env-lab down")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an instance id with shell metacharacters inert", () => {
|
||||
const command = buildEnvLabCleanupCommand({
|
||||
instance: "$(touch pwned)",
|
||||
invocation: sourceInvocation("/tmp/checkout"),
|
||||
});
|
||||
const tokens = tokenizePosix(command);
|
||||
|
||||
// The shell reads the instance id as one literal token and runs no embedded
|
||||
// command.
|
||||
expect(tokens.slice(-2)).toEqual(["--instance", "$(touch pwned)"]);
|
||||
expect(command).not.toContain('"$(touch pwned)"');
|
||||
});
|
||||
|
||||
it("runs the bundled dist entry directly, without the tsx runner", () => {
|
||||
const command = buildEnvLabCleanupCommand({
|
||||
invocation: bundledInvocation("/opt/pkg"),
|
||||
});
|
||||
const tokens = tokenizePosix(command);
|
||||
|
||||
// The published package ships one `dist/index.js` file and no tsx runner, so
|
||||
// node runs that file directly.
|
||||
expect(tokens).toEqual(["node", path.join("/opt/pkg", "dist", "index.js"), "env-lab", "down"]);
|
||||
expect(command).not.toContain("tsx");
|
||||
expect(command).not.toContain(path.join("src", "index.ts"));
|
||||
});
|
||||
|
||||
it("keeps a bundled package path with shell metacharacters inert", () => {
|
||||
const root = "/opt/$(touch pwned)/pkg";
|
||||
const command = buildEnvLabCleanupCommand({ invocation: bundledInvocation(root) });
|
||||
const tokens = tokenizePosix(command);
|
||||
const entry = path.join(root, "dist", "index.js");
|
||||
|
||||
// The shell reads the exact bundled path as one token and runs no embedded
|
||||
// command.
|
||||
expect(tokens).toEqual(["node", entry, "env-lab", "down"]);
|
||||
expect(command).not.toContain(`"${entry}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("env-lab doctor cleanup hint instance", () => {
|
||||
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalInstanceId === undefined) {
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
} else {
|
||||
process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Capture the cleanup hint the doctor prints. The doctor reports through
|
||||
// `p.log`, so the test replaces each channel and reads the captured lines.
|
||||
function captureDoctorMessages(): string[] {
|
||||
const messages: string[] = [];
|
||||
vi.spyOn(p.log, "message").mockImplementation((message?: string) => {
|
||||
messages.push(message ?? "");
|
||||
});
|
||||
vi.spyOn(p.log, "success").mockImplementation(() => {});
|
||||
vi.spyOn(p.log, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(p.log, "info").mockImplementation(() => {});
|
||||
return messages;
|
||||
}
|
||||
|
||||
it("pins the PAPERCLIP_INSTANCE_ID instance when opts.instance is absent", async () => {
|
||||
// The doctor diagnoses the instance that `PAPERCLIP_INSTANCE_ID` selects.
|
||||
// The cleanup hint must target that instance, not the default instance.
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
|
||||
const messages = captureDoctorMessages();
|
||||
|
||||
await envLabDoctorCommand({ instance: undefined });
|
||||
|
||||
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
|
||||
expect(cleanup).toBeDefined();
|
||||
expect(cleanup).toContain("env-lab down");
|
||||
expect(cleanup).toContain("--instance");
|
||||
expect(cleanup).toContain("env-selected-instance");
|
||||
});
|
||||
|
||||
it("pins the explicit instance over PAPERCLIP_INSTANCE_ID", async () => {
|
||||
// An explicit `--instance` flag overrides the environment variable, so the
|
||||
// hint targets the explicit instance the doctor inspected.
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
|
||||
const messages = captureDoctorMessages();
|
||||
|
||||
await envLabDoctorCommand({ instance: "explicit-instance" });
|
||||
|
||||
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
|
||||
expect(cleanup).toBeDefined();
|
||||
expect(cleanup).toContain("--instance");
|
||||
expect(cleanup).toContain("explicit-instance");
|
||||
expect(cleanup).not.toContain("env-selected-instance");
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,7 @@ describe("PaperclipApiClient", () => {
|
||||
/curl http:\/\/localhost:3100\/api\/health/,
|
||||
);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||
/pnpm dev|pnpm paperclipai run/,
|
||||
/pnpm dev|npx paperclipai run/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
resolveWorktreeReseedTargetPaths,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolvePnpmInstallInvocation,
|
||||
resolveCurrentWorktreeEndpoint,
|
||||
resolveWorktreeSeedBackupEngine,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeRepairCommand,
|
||||
@@ -167,6 +168,49 @@ function buildSourceConfig(): PaperclipConfig {
|
||||
}
|
||||
|
||||
describe("worktree helpers", () => {
|
||||
it("uses the repo-local config for the current worktree", () => {
|
||||
const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-"));
|
||||
try {
|
||||
const localConfig = path.join(targetRoot, ".paperclip", "config.json");
|
||||
fs.mkdirSync(path.dirname(localConfig), { recursive: true });
|
||||
fs.writeFileSync(localConfig, "{}\n");
|
||||
process.env.PAPERCLIP_CONFIG = "/tmp/ambient-paperclip/config.json";
|
||||
process.chdir(targetRoot);
|
||||
|
||||
expect(resolveCurrentWorktreeEndpoint()).toMatchObject({
|
||||
rootPath: targetRoot,
|
||||
configPath: localConfig,
|
||||
isCurrent: true,
|
||||
});
|
||||
} finally {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
fs.rmSync(targetRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the repository config from a nested working directory", () => {
|
||||
const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-nested-"));
|
||||
try {
|
||||
execFileSync("git", ["init", "-q"], { cwd: targetRoot });
|
||||
const nestedDirectory = path.join(targetRoot, "packages", "example", "src");
|
||||
const localConfig = path.join(targetRoot, ".paperclip", "config.json");
|
||||
fs.mkdirSync(nestedDirectory, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(localConfig), { recursive: true });
|
||||
fs.writeFileSync(localConfig, "{}\n");
|
||||
process.env.PAPERCLIP_CONFIG = "/tmp/ambient-paperclip/config.json";
|
||||
process.chdir(nestedDirectory);
|
||||
|
||||
expect(resolveCurrentWorktreeEndpoint()).toMatchObject({
|
||||
rootPath: targetRoot,
|
||||
configPath: localConfig,
|
||||
isCurrent: true,
|
||||
});
|
||||
} finally {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
fs.rmSync(targetRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sanitizes instance ids", () => {
|
||||
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
|
||||
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||
|
||||
function isInsideOsTmpDir(targetPath: string): boolean {
|
||||
const tmpRoot = path.resolve(os.tmpdir());
|
||||
const resolved = path.resolve(targetPath);
|
||||
return resolved === tmpRoot || resolved.startsWith(`${tmpRoot}${path.sep}`);
|
||||
}
|
||||
|
||||
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
|
||||
if (config.database.mode === "postgres") {
|
||||
if (!config.database.connectionString) {
|
||||
@@ -37,9 +45,33 @@ export async function databaseCheck(config: PaperclipConfig, configPath?: string
|
||||
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
|
||||
const reportedPath = dataDir;
|
||||
|
||||
// A worktree-mode instance whose data dir lives under the OS temp dir is a red
|
||||
// flag: this is what happens when PAPERCLIP_HOME / PAPERCLIP_IN_WORKTREE leak
|
||||
// into a PRIMARY instance's environment and silently relocate it to a throwaway
|
||||
// temp home, so it boots an empty DB and locks everyone out. (Intentional
|
||||
// ephemeral/CI instances that don't set PAPERCLIP_IN_WORKTREE are not flagged.)
|
||||
// Check BEFORE creating the dir so we don't bootstrap the very temp location
|
||||
// we're warning about.
|
||||
if (isInsideOsTmpDir(dataDir) && process.env.PAPERCLIP_IN_WORKTREE === "true") {
|
||||
return {
|
||||
name: "Database",
|
||||
status: "warn",
|
||||
message:
|
||||
`Embedded PostgreSQL data dir is inside the OS temp directory (${dataDir}) ` +
|
||||
"while running in worktree mode (PAPERCLIP_IN_WORKTREE=true). Data stored here is " +
|
||||
"ephemeral and will be lost on reboot or a temp cleanup. If this is your primary " +
|
||||
"instance, PAPERCLIP_HOME / PAPERCLIP_IN_WORKTREE likely leaked into its environment, " +
|
||||
"pointing it at a throwaway worktree home instead of your real data.",
|
||||
canRepair: false,
|
||||
repairHint:
|
||||
"If this is the primary instance, unset PAPERCLIP_HOME and PAPERCLIP_IN_WORKTREE " +
|
||||
"(or pass --data-dir <persistent path>) and restart so it uses the persistent instance.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(reportedPath, { recursive: true });
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+10
-1
@@ -88,6 +88,15 @@ export class PaperclipApiClient {
|
||||
}, opts);
|
||||
}
|
||||
|
||||
/** Raw binary upload (e.g. one chunked import-transfer part); the body travels as-is. */
|
||||
putRaw<T>(path: string, body: Uint8Array, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, {
|
||||
method: "PUT",
|
||||
body: body as unknown as BodyInit,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
}, opts);
|
||||
}
|
||||
|
||||
delete<T>(path: string, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, { method: "DELETE" }, opts);
|
||||
}
|
||||
@@ -224,7 +233,7 @@ function buildConnectionErrorMessage(input: {
|
||||
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
|
||||
"",
|
||||
"Try:",
|
||||
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
|
||||
"- Start Paperclip with `pnpm dev` (from a source checkout) or `npx paperclipai run`.",
|
||||
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
|
||||
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import pc from "picocolors";
|
||||
import { resolvePublishedVersion, type CommandRunner } from "./install.js";
|
||||
import { packageVersion } from "../version.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const defaultRunCommand: CommandRunner = (command, args, options) =>
|
||||
execFileAsync(command, args, { ...options, encoding: "utf8" });
|
||||
|
||||
export type ChannelName = "stable" | "beta" | "nightly" | "canary";
|
||||
|
||||
export type ChannelDescriptor = {
|
||||
channel: ChannelName;
|
||||
distTag: string;
|
||||
cadence: string;
|
||||
audience: string;
|
||||
};
|
||||
|
||||
// Ordered from most to least stable — the order users should consider them.
|
||||
export const RELEASE_CHANNELS: readonly ChannelDescriptor[] = [
|
||||
{
|
||||
channel: "stable",
|
||||
distTag: "latest",
|
||||
cadence: "manual, soaked in beta for 3+ days",
|
||||
audience: "the recommended release for almost everyone",
|
||||
},
|
||||
{
|
||||
channel: "beta",
|
||||
distTag: "beta",
|
||||
cadence: "manual promotion behind an approval gate",
|
||||
audience: "release candidates: what stable becomes a few days later",
|
||||
},
|
||||
{
|
||||
channel: "nightly",
|
||||
distTag: "nightly",
|
||||
cadence: "once a night, smoke-gated",
|
||||
audience: "yesterday's merges, tested as a unit",
|
||||
},
|
||||
{
|
||||
channel: "canary",
|
||||
distTag: "canary",
|
||||
cadence: "every merge to master",
|
||||
audience: "the bleeding edge",
|
||||
},
|
||||
];
|
||||
|
||||
const CALVER_RE = /^\d{4}\.\d{1,4}\.\d+$/;
|
||||
const PRERELEASE_RE = /^\d{4}\.\d{1,4}\.\d+-(canary|nightly|beta)\.\d+$/;
|
||||
|
||||
// Published versions carry their lane in the version string; the source
|
||||
// checkout's package.json holds a placeholder that matches neither form.
|
||||
export function channelForVersion(version: string): ChannelName | "unknown" {
|
||||
const prerelease = version.match(PRERELEASE_RE);
|
||||
if (prerelease) return prerelease[1] as ChannelName;
|
||||
if (CALVER_RE.test(version)) return "stable";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export type ChannelState = ChannelDescriptor & { version: string | null };
|
||||
|
||||
export async function collectChannelState(
|
||||
runCommand: CommandRunner = defaultRunCommand,
|
||||
): Promise<ChannelState[]> {
|
||||
const resolved = await Promise.allSettled(
|
||||
RELEASE_CHANNELS.map((entry) => resolvePublishedVersion(entry.distTag, runCommand)),
|
||||
);
|
||||
return RELEASE_CHANNELS.map((entry, index) => {
|
||||
const outcome = resolved[index];
|
||||
return {
|
||||
...entry,
|
||||
version: outcome.status === "fulfilled" ? outcome.value : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type ChannelsOptions = { json?: boolean };
|
||||
|
||||
export async function channelsCommand(
|
||||
options: ChannelsOptions = {},
|
||||
runCommand: CommandRunner = defaultRunCommand,
|
||||
): Promise<void> {
|
||||
const state = await collectChannelState(runCommand);
|
||||
const currentChannel = channelForVersion(packageVersion);
|
||||
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
current: { version: packageVersion, channel: currentChannel },
|
||||
channels: state,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.bold("Paperclip release channels"));
|
||||
console.log("");
|
||||
for (const entry of state) {
|
||||
const version = entry.version ?? pc.yellow("unavailable");
|
||||
console.log(` ${pc.bold(entry.channel.padEnd(8))} ${version}`);
|
||||
console.log(` ${" ".repeat(8)} ${pc.dim(`${entry.cadence} — ${entry.audience}`)}`);
|
||||
console.log(` ${" ".repeat(8)} ${pc.dim(`npx paperclipai@${entry.distTag} onboard`)}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (currentChannel === "unknown") {
|
||||
console.log(
|
||||
`This install reports version ${pc.bold(packageVersion)}, which does not map to a published channel (source checkouts report the repository placeholder).`,
|
||||
);
|
||||
} else {
|
||||
console.log(`This install is version ${pc.bold(packageVersion)} on the ${pc.bold(currentChannel)} channel.`);
|
||||
}
|
||||
console.log(`Docker images use the same names: ghcr.io/paperclipai/paperclip:{latest,beta,nightly,canary}`);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import * as p from "@clack/prompts";
|
||||
@@ -12,10 +13,24 @@ import type {
|
||||
CompanyPortabilityPreviewResult,
|
||||
CompanyPortabilityImportResult,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
companyImportTransferApplyPath,
|
||||
companyImportTransferPartPath,
|
||||
companyImportTransferPreviewPath,
|
||||
COMPANY_IMPORT_TRANSFERS_ROUTE_PATH,
|
||||
type CompanyImportTransferCreated,
|
||||
type CompanyImportTransferDeclaration,
|
||||
} from "@paperclipai/shared/company-import-transfer";
|
||||
import { getTelemetryClient, trackCompanyImported } from "../../telemetry.js";
|
||||
import { ApiRequestError } from "../../client/http.js";
|
||||
import { ApiRequestError, type PaperclipApiClient } from "../../client/http.js";
|
||||
import { openUrl } from "../../client/board-auth.js";
|
||||
import { binaryContentTypeByExtension, bytesToPortableFileEntry, isBlobStorePath, readZipArchive } from "./zip.js";
|
||||
import {
|
||||
binaryContentTypeByExtension,
|
||||
bytesToPortableFileEntry,
|
||||
createStoredZipArchive,
|
||||
isBlobStorePath,
|
||||
readZipArchive,
|
||||
} from "./zip.js";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
apiPath,
|
||||
@@ -927,23 +942,23 @@ async function pathExists(inputPath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectPackageFiles(
|
||||
async function collectPackageFileBytes(
|
||||
root: string,
|
||||
current: string,
|
||||
files: Record<string, CompanyPortabilityFileEntry>,
|
||||
files: Record<string, Uint8Array>,
|
||||
): Promise<void> {
|
||||
const entries = await readdir(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".git")) continue;
|
||||
const absolutePath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectPackageFiles(root, absolutePath, files);
|
||||
await collectPackageFileBytes(root, absolutePath, files);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
|
||||
if (!shouldIncludePortableFile(relativePath)) continue;
|
||||
files[relativePath] = bytesToPortableFileEntry(relativePath, await readFile(absolutePath));
|
||||
files[relativePath] = await readFile(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,14 +980,232 @@ export async function resolveInlineSourceFromPath(inputPath: string): Promise<{
|
||||
}
|
||||
|
||||
const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved);
|
||||
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
||||
await collectPackageFiles(rootDir, rootDir, files);
|
||||
const fileBytes: Record<string, Uint8Array> = {};
|
||||
await collectPackageFileBytes(rootDir, rootDir, fileBytes);
|
||||
return {
|
||||
rootPath: path.basename(rootDir),
|
||||
files,
|
||||
files: Object.fromEntries(
|
||||
Object.entries(fileBytes).map(([relativePath, bytes]) => [
|
||||
relativePath,
|
||||
bytesToPortableFileEntry(relativePath, bytes),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Chunked transfer flow for large local packages ───────────────────
|
||||
//
|
||||
// A local package over the threshold is not posted as one inline JSON body:
|
||||
// its zip is declared as a chunked transfer (whole-file and per-part sha256),
|
||||
// the parts are uploaded individually with per-part retries, and preview and
|
||||
// apply run server-side against the assembled spool. Re-declaring the same
|
||||
// content — after a failure or an interrupted run — resumes the prior
|
||||
// transfer, so only the parts the server is missing are ever re-uploaded.
|
||||
|
||||
export const CHUNKED_IMPORT_THRESHOLD_BYTES = 48 * 1024 * 1024;
|
||||
// Imports into an EXISTING company post to /api/companies/:id/imports/*,
|
||||
// which sits behind the server's default 10 MB JSON parser — only the
|
||||
// generic /api/companies/import path carries the 64 MB portable limit. The
|
||||
// chunk decision for existing targets therefore uses this lower threshold
|
||||
// (margin under 10 MB for the envelope), or the inline body would 413.
|
||||
export const EXISTING_COMPANY_CHUNKED_IMPORT_THRESHOLD_BYTES = 8 * 1024 * 1024;
|
||||
export const IMPORT_TRANSFER_PART_SIZE_BYTES = 32 * 1024 * 1024;
|
||||
const IMPORT_TRANSFER_PART_ATTEMPTS = 3;
|
||||
|
||||
// ── Inline request size estimation ───────────────────────────────────
|
||||
//
|
||||
// Mirrors `estimateInlineImportBytes` in ui/src/lib/import-preflight.ts (the
|
||||
// CLI cannot import from ui/) — keep the math on both sides in sync. The
|
||||
// server enforces its body limit on raw request bytes, so each entry is
|
||||
// measured the way it actually travels: JSON-escaped UTF-8 for text
|
||||
// (multi-byte characters and escape sequences both inflate past
|
||||
// `String.length`), and the base64 payload plus its object structure for
|
||||
// binary entries (base64 and MIME types are ASCII, one byte per character).
|
||||
|
||||
const inlineEstimateUtf8 = new TextEncoder();
|
||||
|
||||
// Fixed serialization overhead of a base64 entry object around its data and
|
||||
// contentType values: {"encoding":"base64","data":"…","contentType":"…"}.
|
||||
const BASE64_ENTRY_STRUCTURE_BYTES = '{"encoding":"base64","data":"","contentType":""}'.length;
|
||||
|
||||
// Allowance for everything in the request body besides the files map itself
|
||||
// (rootPath, include flags, target, collision strategy, adapter overrides,
|
||||
// braces and commas). Deliberately generous so the estimate never undercounts.
|
||||
const REQUEST_ENVELOPE_ALLOWANCE_BYTES = 256 * 1024;
|
||||
|
||||
function fileEntryInlineBytes(entry: CompanyPortabilityFileEntry): number {
|
||||
if (typeof entry === "string") return inlineEstimateUtf8.encode(JSON.stringify(entry)).length;
|
||||
return BASE64_ENTRY_STRUCTURE_BYTES + entry.data.length + (entry.contentType?.length ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate JSON request size of an inline import: JSON-escaped UTF-8 text
|
||||
* bytes, base64 payloads with their entry structure, the serialized file-path
|
||||
* keys (thousands of paths are real bytes), and an envelope allowance for the
|
||||
* rest of the request body.
|
||||
*/
|
||||
function estimateInlineImportBytes(files: Record<string, CompanyPortabilityFileEntry>): number {
|
||||
let total = REQUEST_ENVELOPE_ALLOWANCE_BYTES;
|
||||
for (const [filePath, entry] of Object.entries(files)) {
|
||||
// "path": entry, → key bytes + colon + comma.
|
||||
total += inlineEstimateUtf8.encode(JSON.stringify(filePath)).length + 2 + fileEntryInlineBytes(entry);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export interface ImportTransferUploadProgress {
|
||||
uploadedParts: number;
|
||||
totalParts: number;
|
||||
uploadedBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export function buildImportTransferManifest(zipBytes: Uint8Array): CompanyImportTransferDeclaration {
|
||||
const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
||||
const parts: CompanyImportTransferDeclaration["parts"] = [];
|
||||
for (let offset = 0; offset < zipBytes.length; offset += IMPORT_TRANSFER_PART_SIZE_BYTES) {
|
||||
const byteSize = Math.min(IMPORT_TRANSFER_PART_SIZE_BYTES, zipBytes.length - offset);
|
||||
parts.push({
|
||||
index: parts.length,
|
||||
byteSize,
|
||||
sha256: sha256(zipBytes.subarray(offset, offset + byteSize)),
|
||||
});
|
||||
}
|
||||
return {
|
||||
totalBytes: zipBytes.length,
|
||||
zipSha256: sha256(zipBytes),
|
||||
partSizeBytes: IMPORT_TRANSFER_PART_SIZE_BYTES,
|
||||
parts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local import source into raw zip bytes when its package is too
|
||||
* large to travel as one inline JSON body: a .zip file is read as-is (so its
|
||||
* declared hashes match the file on disk), a folder is packaged as a stored
|
||||
* zip in memory with the same walk filters the inline path uses. Both source
|
||||
* kinds are measured twice — raw bytes as a fast path, then the estimated
|
||||
* inline request size, because base64 inflates binary entries ~4/3 and a
|
||||
* compressed zip can expand far past its file size. Returns null for sources
|
||||
* under the threshold on both measures — those keep the inline JSON path.
|
||||
*/
|
||||
export async function resolveChunkedImportZip(
|
||||
inputPath: string,
|
||||
thresholdBytes: number = CHUNKED_IMPORT_THRESHOLD_BYTES,
|
||||
): Promise<{
|
||||
zipBytes: Uint8Array;
|
||||
rootPath: string;
|
||||
} | null> {
|
||||
const resolved = path.resolve(inputPath);
|
||||
const resolvedStat = await stat(resolved);
|
||||
if (resolvedStat.isFile() && path.extname(resolved).toLowerCase() === ".zip") {
|
||||
const zipBytes = new Uint8Array(await readFile(resolved));
|
||||
const rootPath = path.basename(resolved, ".zip");
|
||||
if (resolvedStat.size > thresholdBytes) return { zipBytes, rootPath };
|
||||
// A small compressed zip can still expand past server caps as inline
|
||||
// JSON (text compresses well and binary re-inflates ~4/3 as base64), so
|
||||
// the stay-inline decision uses the estimated request size of the same
|
||||
// entries the inline path would send. An unreadable zip stays inline so
|
||||
// that path surfaces its canonical parse error.
|
||||
let archive: Awaited<ReturnType<typeof readZipArchive>>;
|
||||
try {
|
||||
archive = await readZipArchive(zipBytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (estimateInlineImportBytes(archive.files) <= thresholdBytes) return null;
|
||||
return { zipBytes, rootPath };
|
||||
}
|
||||
if (!resolvedStat.isDirectory()) return null;
|
||||
const fileBytes: Record<string, Uint8Array> = {};
|
||||
await collectPackageFileBytes(resolved, resolved, fileBytes);
|
||||
const rootPath = path.basename(resolved);
|
||||
// Content bytes alone already past the threshold means the stored zip
|
||||
// (content plus headers) is too.
|
||||
const contentBytes = Object.values(fileBytes).reduce((sum, bytes) => sum + bytes.length, 0);
|
||||
if (contentBytes <= thresholdBytes) {
|
||||
// Raw bytes under the threshold can still blow past server caps once the
|
||||
// inline body is built (binary entries travel base64-inflated), so the
|
||||
// stay-inline decision is made on the estimated request size — the same
|
||||
// entries the inline path would send.
|
||||
const inlineEntries = Object.fromEntries(
|
||||
Object.entries(fileBytes).map(([relativePath, bytes]) => [
|
||||
relativePath,
|
||||
bytesToPortableFileEntry(relativePath, bytes),
|
||||
]),
|
||||
);
|
||||
if (estimateInlineImportBytes(inlineEntries) <= thresholdBytes) return null;
|
||||
}
|
||||
return { zipBytes: createStoredZipArchive(fileBytes, rootPath), rootPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare (or resume) the transfer for these zip bytes and upload every part
|
||||
* the server reports missing, sequentially with per-part retries. Resolves
|
||||
* with the transfer id once the server holds every part.
|
||||
*/
|
||||
export async function uploadCompanyImportTransfer(
|
||||
api: Pick<PaperclipApiClient, "post" | "putRaw">,
|
||||
zipBytes: Uint8Array,
|
||||
opts: { onProgress?: (progress: ImportTransferUploadProgress) => void } = {},
|
||||
): Promise<string> {
|
||||
const manifest = buildImportTransferManifest(zipBytes);
|
||||
const created = await api.post<CompanyImportTransferCreated>(
|
||||
`/api/companies${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}`,
|
||||
manifest,
|
||||
);
|
||||
if (!created) {
|
||||
throw new Error("Import transfer declaration returned no data.");
|
||||
}
|
||||
if (created.alreadyCompleted) {
|
||||
// The server keys transfers by content, and this exact zip already
|
||||
// finished an apply — its spooled parts are gone, so it cannot re-run.
|
||||
throw new Error(
|
||||
"This exact package was already imported by a completed transfer. Re-export the package to import it again.",
|
||||
);
|
||||
}
|
||||
const missing = new Set(created.missingParts);
|
||||
let uploadedParts = manifest.parts.length - missing.size;
|
||||
let uploadedBytes = manifest.parts.reduce(
|
||||
(sum, part) => (missing.has(part.index) ? sum : sum + part.byteSize),
|
||||
0,
|
||||
);
|
||||
for (const part of manifest.parts) {
|
||||
if (!missing.has(part.index)) continue;
|
||||
const offset = part.index * manifest.partSizeBytes;
|
||||
const bytes = zipBytes.subarray(offset, offset + part.byteSize);
|
||||
let lastError: unknown = null;
|
||||
let uploaded = false;
|
||||
for (let attempt = 0; attempt < IMPORT_TRANSFER_PART_ATTEMPTS && !uploaded; attempt += 1) {
|
||||
try {
|
||||
await api.putRaw(
|
||||
`/api/companies${companyImportTransferPartPath(created.transferId, part.index)}`,
|
||||
bytes,
|
||||
);
|
||||
uploaded = true;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
if (!uploaded) {
|
||||
// Parts already uploaded stay spooled server-side; re-running the
|
||||
// import resumes from them instead of starting over.
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Import transfer part ${part.index} failed to upload.`);
|
||||
}
|
||||
uploadedParts += 1;
|
||||
uploadedBytes += part.byteSize;
|
||||
opts.onProgress?.({
|
||||
uploadedParts,
|
||||
totalParts: manifest.parts.length,
|
||||
uploadedBytes,
|
||||
totalBytes: manifest.totalBytes,
|
||||
});
|
||||
}
|
||||
return created.transferId;
|
||||
}
|
||||
|
||||
export async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise<void> {
|
||||
const root = path.resolve(outDir);
|
||||
await mkdir(root, { recursive: true });
|
||||
@@ -1464,6 +1697,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||
let sourcePayload:
|
||||
| { type: "inline"; rootPath?: string | null; files: Record<string, CompanyPortabilityFileEntry> }
|
||||
| { type: "github"; url: string };
|
||||
let chunkedZip: { zipBytes: Uint8Array; rootPath: string } | null = null;
|
||||
|
||||
const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from);
|
||||
const isGithubSource = looksLikeRepoUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath);
|
||||
@@ -1480,12 +1714,24 @@ export function registerCompanyCommands(program: Command): void {
|
||||
if (opts.ref?.trim()) {
|
||||
throw new Error("--ref is only supported for GitHub import sources.");
|
||||
}
|
||||
const inline = await resolveInlineSourceFromPath(from);
|
||||
sourcePayload = {
|
||||
type: "inline",
|
||||
rootPath: inline.rootPath,
|
||||
files: inline.files,
|
||||
};
|
||||
chunkedZip = await resolveChunkedImportZip(
|
||||
from,
|
||||
target === "existing"
|
||||
? EXISTING_COMPANY_CHUNKED_IMPORT_THRESHOLD_BYTES
|
||||
: CHUNKED_IMPORT_THRESHOLD_BYTES,
|
||||
);
|
||||
if (chunkedZip) {
|
||||
// Too large for one request: the zip travels as a chunked
|
||||
// transfer, so the inline files map is never built or sent.
|
||||
sourcePayload = { type: "inline", rootPath: chunkedZip.rootPath, files: {} };
|
||||
} else {
|
||||
const inline = await resolveInlineSourceFromPath(from);
|
||||
sourcePayload = {
|
||||
type: "inline",
|
||||
rootPath: inline.rootPath,
|
||||
files: inline.files,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const sourceLabel = formatSourceLabel(sourcePayload);
|
||||
@@ -1496,15 +1742,40 @@ export function registerCompanyCommands(program: Command): void {
|
||||
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
|
||||
});
|
||||
|
||||
// The transfer meta mirrors the inline preview payload minus its
|
||||
// `source` — the source is the assembled zip, spooled server-side.
|
||||
const transferMeta = {
|
||||
include,
|
||||
target: targetPayload,
|
||||
agents,
|
||||
collisionStrategy: collision,
|
||||
};
|
||||
let transferId: string | null = null;
|
||||
if (chunkedZip) {
|
||||
transferId = await uploadCompanyImportTransfer(ctx.api, chunkedZip.zipBytes, {
|
||||
onProgress: ctx.json
|
||||
? undefined
|
||||
: ({ uploadedParts, totalParts, uploadedBytes, totalBytes }) => {
|
||||
console.log(
|
||||
pc.dim(
|
||||
`Uploaded part ${uploadedParts}/${totalParts} (${Math.round(uploadedBytes / (1024 * 1024))} of ${Math.round(totalBytes / (1024 * 1024))} MB)`,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
const transferPreviewPath = transferId
|
||||
? `/api/companies${companyImportTransferPreviewPath(transferId)}`
|
||||
: null;
|
||||
|
||||
let selectedFiles: string[] | undefined;
|
||||
if (interactiveView && !opts.yes && !opts.include?.trim()) {
|
||||
const initialPreview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, {
|
||||
source: sourcePayload,
|
||||
include,
|
||||
target: targetPayload,
|
||||
agents,
|
||||
collisionStrategy: collision,
|
||||
});
|
||||
const initialPreview = transferPreviewPath
|
||||
? await ctx.api.post<CompanyPortabilityPreviewResult>(transferPreviewPath, transferMeta)
|
||||
: await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, {
|
||||
source: sourcePayload,
|
||||
...transferMeta,
|
||||
});
|
||||
if (!initialPreview) {
|
||||
throw new Error("Import preview returned no data.");
|
||||
}
|
||||
@@ -1513,13 +1784,15 @@ export function registerCompanyCommands(program: Command): void {
|
||||
|
||||
const previewPayload = {
|
||||
source: sourcePayload,
|
||||
include,
|
||||
target: targetPayload,
|
||||
agents,
|
||||
collisionStrategy: collision,
|
||||
...transferMeta,
|
||||
selectedFiles,
|
||||
};
|
||||
const preview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, previewPayload);
|
||||
const preview = transferPreviewPath
|
||||
? await ctx.api.post<CompanyPortabilityPreviewResult>(transferPreviewPath, {
|
||||
...transferMeta,
|
||||
selectedFiles,
|
||||
})
|
||||
: await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, previewPayload);
|
||||
if (!preview) {
|
||||
throw new Error("Import preview returned no data.");
|
||||
}
|
||||
@@ -1576,10 +1849,15 @@ export function registerCompanyCommands(program: Command): void {
|
||||
targetMode: targetPayload.mode,
|
||||
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
|
||||
});
|
||||
const imported = await ctx.api.post<CompanyPortabilityImportResult>(importApiPath, {
|
||||
...previewPayload,
|
||||
adapterOverrides,
|
||||
});
|
||||
const imported = transferId
|
||||
? await ctx.api.post<CompanyPortabilityImportResult>(
|
||||
`/api/companies${companyImportTransferApplyPath(transferId)}`,
|
||||
{ ...transferMeta, selectedFiles, adapterOverrides },
|
||||
)
|
||||
: await ctx.api.post<CompanyPortabilityImportResult>(importApiPath, {
|
||||
...previewPayload,
|
||||
adapterOverrides,
|
||||
});
|
||||
if (!imported) {
|
||||
throw new Error("Import request returned no data.");
|
||||
}
|
||||
|
||||
@@ -8,3 +8,110 @@ export {
|
||||
isBlobStorePath,
|
||||
readZipArchive,
|
||||
} from "@paperclipai/shared/portability-zip";
|
||||
|
||||
// STORE-only zip writer used to package a local folder in memory for the
|
||||
// chunked import transfer path. STORE keeps the writer dependency-free; the
|
||||
// transfer slices the raw archive bytes, so compression only trades CPU for
|
||||
// part count. Classic zip only: entry counts and offsets past the 16/32-bit
|
||||
// header fields would need zip64, which the reader side does not require and
|
||||
// this writer refuses to emit.
|
||||
|
||||
const ZIP_MAX_ENTRIES = 0xffff;
|
||||
const ZIP_MAX_OFFSET_BYTES = 0xffffffff;
|
||||
|
||||
function writeUint16(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
}
|
||||
|
||||
function writeUint32(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
target[offset + 2] = (value >>> 16) & 0xff;
|
||||
target[offset + 3] = (value >>> 24) & 0xff;
|
||||
}
|
||||
|
||||
function crc32(bytes: Uint8Array) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stored (uncompressed) zip of `files` under a single `rootPath/`
|
||||
* top-level directory — the layout `readZipArchive` folds back into the
|
||||
* inline `{ rootPath, files }` bundle. Entries are written in sorted path
|
||||
* order and carry no timestamps, so the same content always produces the
|
||||
* same bytes and a re-run resumes its content-addressed transfer.
|
||||
*/
|
||||
export function createStoredZipArchive(files: Record<string, Uint8Array>, rootPath: string): Uint8Array {
|
||||
const entries = Object.entries(files).sort(([left], [right]) => left.localeCompare(right));
|
||||
if (entries.length > ZIP_MAX_ENTRIES) {
|
||||
throw new Error(`Package has too many files to zip (${entries.length}; the zip format caps at ${ZIP_MAX_ENTRIES}).`);
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const localChunks: Uint8Array[] = [];
|
||||
const centralChunks: Uint8Array[] = [];
|
||||
let localOffset = 0;
|
||||
|
||||
for (const [relativePath, body] of entries) {
|
||||
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
|
||||
const checksum = crc32(body);
|
||||
|
||||
const localHeader = new Uint8Array(30 + fileName.length);
|
||||
writeUint32(localHeader, 0, 0x04034b50);
|
||||
writeUint16(localHeader, 4, 20);
|
||||
writeUint16(localHeader, 6, 0x0800);
|
||||
writeUint16(localHeader, 8, 0);
|
||||
writeUint32(localHeader, 14, checksum);
|
||||
writeUint32(localHeader, 18, body.length);
|
||||
writeUint32(localHeader, 22, body.length);
|
||||
writeUint16(localHeader, 26, fileName.length);
|
||||
localHeader.set(fileName, 30);
|
||||
|
||||
const centralHeader = new Uint8Array(46 + fileName.length);
|
||||
writeUint32(centralHeader, 0, 0x02014b50);
|
||||
writeUint16(centralHeader, 4, 20);
|
||||
writeUint16(centralHeader, 6, 20);
|
||||
writeUint16(centralHeader, 8, 0x0800);
|
||||
writeUint16(centralHeader, 10, 0);
|
||||
writeUint32(centralHeader, 16, checksum);
|
||||
writeUint32(centralHeader, 20, body.length);
|
||||
writeUint32(centralHeader, 24, body.length);
|
||||
writeUint16(centralHeader, 28, fileName.length);
|
||||
writeUint32(centralHeader, 42, localOffset);
|
||||
centralHeader.set(fileName, 46);
|
||||
|
||||
localChunks.push(localHeader, body);
|
||||
centralChunks.push(centralHeader);
|
||||
localOffset += localHeader.length + body.length;
|
||||
if (body.length > ZIP_MAX_OFFSET_BYTES || localOffset > ZIP_MAX_OFFSET_BYTES) {
|
||||
throw new Error("Package is too large to zip in memory (zip64 archives are not supported).");
|
||||
}
|
||||
}
|
||||
|
||||
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const archive = new Uint8Array(localOffset + centralDirectoryLength + 22);
|
||||
let offset = 0;
|
||||
for (const chunk of localChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
const centralDirectoryOffset = offset;
|
||||
for (const chunk of centralChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
writeUint32(archive, offset, 0x06054b50);
|
||||
writeUint16(archive, offset + 8, entries.length);
|
||||
writeUint16(archive, offset + 10, entries.length);
|
||||
writeUint32(archive, offset + 12, centralDirectoryLength);
|
||||
writeUint32(archive, offset + 16, centralDirectoryOffset);
|
||||
|
||||
return archive;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Command } from "commander";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
@@ -111,6 +112,76 @@ export async function envLabDownCommand(opts: { instance?: string; json?: boolea
|
||||
p.log.message(`State: ${pc.dim(statePath)}`);
|
||||
}
|
||||
|
||||
// Quote one argument for a POSIX shell. The env-lab cleanup hint is copyable, so
|
||||
// a contributor can paste it into a shell. A checkout path can hold shell
|
||||
// metacharacters, such as `$`, a backtick, or a double quote. Inside double
|
||||
// quotes a POSIX shell still expands `$(...)`, a backtick pair, and `$NAME`, and
|
||||
// a double quote in the path ends the quoted span. So double quotes do not make
|
||||
// the path safe. Single quotes stop every expansion. This function wraps the
|
||||
// value in single quotes and rewrites each embedded single quote as the `'\''`
|
||||
// sequence. The shell then reads the exact path and runs no embedded command.
|
||||
function shellQuoteArgument(value: string): string {
|
||||
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// Describe how to re-run the env-lab CLI to stop the fixture. The bundled build
|
||||
// emits one `dist/index.js` file, so node runs that file directly and `tsxBin`
|
||||
// is `null`. A source checkout runs `src/index.ts` through the checked-out tsx
|
||||
// runner, because the entry is TypeScript.
|
||||
interface EnvLabCliInvocation {
|
||||
entry: string;
|
||||
tsxBin: string | null;
|
||||
}
|
||||
|
||||
// Resolve how to re-run the CLI from the running module location. The cleanup
|
||||
// hint must run the same CLI that prints it, so it stops the correct version.
|
||||
// `import.meta.url` gives the running module. The bundled build runs this module
|
||||
// from `<cli>/dist/index.js`, so the hint runs that exact file with node. The
|
||||
// published package ships no `src` directory and no tsx runner. A source
|
||||
// checkout runs this module from `<cli>/src/commands/env-lab.ts`, so the hint
|
||||
// runs `<cli>/src/index.ts` through the checked-out tsx runner. This resolver
|
||||
// reads an absolute path from the module location, so the hint works from any
|
||||
// working directory. The `modulePath` parameter is a test seam; production
|
||||
// callers use the running module path.
|
||||
export function resolveEnvLabCliInvocation(
|
||||
modulePath: string = fileURLToPath(import.meta.url),
|
||||
): EnvLabCliInvocation {
|
||||
const moduleDir = path.dirname(modulePath);
|
||||
const isSourceCheckout =
|
||||
path.basename(moduleDir) === "commands" && path.basename(path.dirname(moduleDir)) === "src";
|
||||
if (isSourceCheckout) {
|
||||
const cliRoot = path.resolve(moduleDir, "..", "..");
|
||||
return {
|
||||
entry: path.join(cliRoot, "src", "index.ts"),
|
||||
tsxBin: path.join(cliRoot, "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
};
|
||||
}
|
||||
return { entry: modulePath, tsxBin: null };
|
||||
}
|
||||
|
||||
// Build the env-lab cleanup hint as a copyable shell command. The hint stops the
|
||||
// fixture that `env-lab doctor` inspected. It runs the same CLI that prints it,
|
||||
// so it stops the correct version, and it forwards the inspected instance, so it
|
||||
// stops the correct instance. It passes an inert `argv` value, so no shell reads
|
||||
// the argument. Each path and the instance id pass through `shellQuoteArgument`,
|
||||
// so a shell metacharacter stays inert when a contributor pastes the command.
|
||||
// The `invocation` parameter is a test seam; production callers use the resolved
|
||||
// running-module invocation.
|
||||
export function buildEnvLabCleanupCommand(
|
||||
opts: { instance?: string; invocation?: EnvLabCliInvocation } = {},
|
||||
): string {
|
||||
const invocation = opts.invocation ?? resolveEnvLabCliInvocation();
|
||||
const parts = ["node"];
|
||||
if (invocation.tsxBin !== null) {
|
||||
parts.push(shellQuoteArgument(invocation.tsxBin));
|
||||
}
|
||||
parts.push(shellQuoteArgument(invocation.entry), "env-lab down");
|
||||
if (opts.instance !== undefined) {
|
||||
parts.push("--instance", shellQuoteArgument(opts.instance));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export async function envLabDoctorCommand(opts: { instance?: string; json?: boolean }) {
|
||||
const status = await collectEnvLabDoctorStatus(opts);
|
||||
|
||||
@@ -138,7 +209,19 @@ export async function envLabDoctorCommand(opts: { instance?: string; json?: bool
|
||||
p.log.message(`State: ${pc.dim(status.statePath)}`);
|
||||
}
|
||||
|
||||
p.log.message(`Cleanup: ${pc.dim("pnpm paperclipai env-lab down")}`);
|
||||
// The cleanup hint runs the same CLI that prints it, so it stops the correct
|
||||
// version. The bundled build runs `dist/index.js`; a source checkout runs
|
||||
// `src/index.ts` through the checked-out tsx runner. The hint uses absolute
|
||||
// paths, so it works from any working directory. It passes an inert `argv`
|
||||
// value, so no shell reads the argument. See `doc/CLI.md`, "safe invocation".
|
||||
//
|
||||
// The doctor diagnoses the instance that `resolvePaperclipInstanceId` selects
|
||||
// from `opts.instance` or the `PAPERCLIP_INSTANCE_ID` environment variable.
|
||||
// The hint pins that resolved instance, so a contributor who pastes the hint
|
||||
// in a shell without `PAPERCLIP_INSTANCE_ID` stops the diagnosed fixture, not
|
||||
// the default instance.
|
||||
const cleanupInstance = resolvePaperclipInstanceId(opts.instance);
|
||||
p.log.message(`Cleanup: ${pc.dim(buildEnvLabCleanupCommand({ instance: cleanupInstance }))}`);
|
||||
}
|
||||
|
||||
export function registerEnvLabCommands(program: Command) {
|
||||
|
||||
@@ -2218,10 +2218,13 @@ async function closeDb(db: ClosableDb): Promise<void> {
|
||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
|
||||
function resolveCurrentEndpoint(): ResolvedWorktreeEndpoint {
|
||||
export function resolveCurrentWorktreeEndpoint(): ResolvedWorktreeEndpoint {
|
||||
const cwd = path.resolve(process.cwd());
|
||||
const rootPath = detectGitWorkspaceInfo(cwd)?.root ?? cwd;
|
||||
const localConfigPath = path.join(rootPath, ".paperclip", "config.json");
|
||||
return {
|
||||
rootPath: path.resolve(process.cwd()),
|
||||
configPath: resolveConfigPath(),
|
||||
rootPath,
|
||||
configPath: existsSync(localConfigPath) ? localConfigPath : resolveConfigPath(),
|
||||
label: "current",
|
||||
isCurrent: true,
|
||||
};
|
||||
@@ -2233,7 +2236,7 @@ function resolveAttachmentLookupStorages(input: {
|
||||
}): ConfiguredStorage[] {
|
||||
const orderedConfigPaths = [
|
||||
input.sourceEndpoint.configPath,
|
||||
resolveCurrentEndpoint().configPath,
|
||||
resolveCurrentWorktreeEndpoint().configPath,
|
||||
input.targetEndpoint.configPath,
|
||||
...toMergeSourceChoices(process.cwd())
|
||||
.filter((choice) => choice.hasPaperclipConfig)
|
||||
@@ -2804,7 +2807,7 @@ export async function worktreeListCommand(opts: WorktreeListOptions): Promise<vo
|
||||
|
||||
function resolveEndpointFromChoice(choice: MergeSourceChoice): ResolvedWorktreeEndpoint {
|
||||
if (choice.isCurrent) {
|
||||
return resolveCurrentEndpoint();
|
||||
return resolveCurrentWorktreeEndpoint();
|
||||
}
|
||||
return {
|
||||
rootPath: choice.worktree,
|
||||
@@ -2824,7 +2827,7 @@ function resolveWorktreeEndpointFromSelector(
|
||||
throw new Error("Worktree selector cannot be empty.");
|
||||
}
|
||||
|
||||
const currentEndpoint = resolveCurrentEndpoint();
|
||||
const currentEndpoint = resolveCurrentWorktreeEndpoint();
|
||||
if (allowCurrent && trimmed === "current") {
|
||||
return currentEndpoint;
|
||||
}
|
||||
@@ -2866,7 +2869,7 @@ function resolveWorktreeEndpointFromSelector(
|
||||
|
||||
async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise<ResolvedWorktreeEndpoint> {
|
||||
const excluded = excludeWorktreePath ? path.resolve(excludeWorktreePath) : null;
|
||||
const currentEndpoint = resolveCurrentEndpoint();
|
||||
const currentEndpoint = resolveCurrentWorktreeEndpoint();
|
||||
const choices = toMergeSourceChoices(process.cwd())
|
||||
.filter((choice) => choice.hasPaperclipConfig || choice.isCurrent)
|
||||
.filter((choice) => path.resolve(choice.worktree) !== excluded)
|
||||
@@ -3295,7 +3298,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
|
||||
const targetEndpoint = opts.to
|
||||
? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true })
|
||||
: resolveCurrentEndpoint();
|
||||
: resolveCurrentWorktreeEndpoint();
|
||||
const sourceEndpoint = opts.from
|
||||
? resolveWorktreeEndpointFromSelector(opts.from, { allowCurrent: true })
|
||||
: sourceArg
|
||||
@@ -3400,7 +3403,7 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
||||
|
||||
const targetEndpoint = opts.to
|
||||
? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true })
|
||||
: resolveCurrentEndpoint();
|
||||
: resolveCurrentWorktreeEndpoint();
|
||||
const source = resolveWorktreeReseedSource(opts);
|
||||
|
||||
if (path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "commander";
|
||||
import { onboard } from "./commands/onboard.js";
|
||||
import { doctor } from "./commands/doctor.js";
|
||||
import { envCommand } from "./commands/env.js";
|
||||
import { channelsCommand } from "./commands/channels.js";
|
||||
import { configure } from "./commands/configure.js";
|
||||
import { addAllowedHostname } from "./commands/allowed-hostname.js";
|
||||
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
||||
@@ -130,6 +131,14 @@ program
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.action(envCommand);
|
||||
|
||||
program
|
||||
.command("channels")
|
||||
.description("Show the release channels and which one this install follows")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (opts) => {
|
||||
await channelsCommand(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command("configure")
|
||||
.description("Update configuration sections")
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Release Channels
|
||||
|
||||
Paperclip ships on four channels. Pick the one that matches your appetite for
|
||||
freshness versus stability — switching is just a matter of which version you
|
||||
install.
|
||||
|
||||
| Channel | What it is | Updates | npm | Docker |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `stable` | The recommended release | every week or two | `paperclipai@latest` | `ghcr.io/paperclipai/paperclip:latest` |
|
||||
| `beta` | Release candidates soaking before stable | when promoted | `paperclipai@beta` | `ghcr.io/paperclipai/paperclip:beta` |
|
||||
| `nightly` | Yesterday's merges, smoke-tested as a unit | once a night | `paperclipai@nightly` | `ghcr.io/paperclipai/paperclip:nightly` |
|
||||
| `canary` | Every merge to `master`, as it happens | many times a day | `paperclipai@canary` | `ghcr.io/paperclipai/paperclip:canary` |
|
||||
|
||||
## Choosing a channel
|
||||
|
||||
**stable** is the right choice for almost everyone. It only moves when a
|
||||
release has been explicitly vetted and promoted by a maintainer, and every
|
||||
stable must first soak as a beta for at least 3 days.
|
||||
|
||||
**beta** is for people who want the next stable early. A beta is a nightly
|
||||
that a maintainer hand-picked and explicitly promoted behind an approval
|
||||
gate, and it is re-smoked after publishing. Betas are the release candidates:
|
||||
what you run on beta today is what stable becomes a few days later.
|
||||
|
||||
**nightly** is for people who want new features quickly but not the churn of
|
||||
tracking every merge. Once a night, the newest master build that published
|
||||
green is run through the full release smoke suite (real Docker container, real
|
||||
onboarding flow, browser-driven). Only if that passes does it ship as the
|
||||
nightly. If smoke fails, there is no nightly that night — the channel never
|
||||
ships a build that failed its checks.
|
||||
|
||||
**canary** is the bleeding edge: it publishes on every merge to `master`.
|
||||
It is primarily the lane that continuously exercises our release automation,
|
||||
but it's available to anyone who wants the newest bits and accepts the risk.
|
||||
|
||||
## Installing from a channel
|
||||
|
||||
npm / npx:
|
||||
|
||||
```bash
|
||||
npx paperclipai@latest onboard # stable
|
||||
npx paperclipai@beta onboard
|
||||
npx paperclipai@nightly onboard
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/paperclipai/paperclip:latest # stable
|
||||
docker pull ghcr.io/paperclipai/paperclip:beta
|
||||
docker pull ghcr.io/paperclipai/paperclip:nightly
|
||||
docker pull ghcr.io/paperclipai/paperclip:canary
|
||||
```
|
||||
|
||||
Every image is also published as `:sha-<short-sha>` for exact pinning, and
|
||||
stable images additionally get `:YYYY.MDD.P` version tags.
|
||||
|
||||
## Seeing where you are
|
||||
|
||||
```bash
|
||||
npx paperclipai channels
|
||||
```
|
||||
|
||||
prints every channel with the version it currently resolves to, the install
|
||||
command for each, and which channel your install follows (with `--json` for
|
||||
scripting).
|
||||
|
||||
## Switching channels
|
||||
|
||||
Channel choice is per-install: install from a different tag and you're on that
|
||||
channel. Moving forward (stable → nightly) is always safe. Moving backward
|
||||
(nightly → stable) can mean running an older schema than your data was created
|
||||
with — treat a downgrade like a restore and keep a backup of your data
|
||||
directory before switching down.
|
||||
|
||||
## Reading version strings
|
||||
|
||||
The version tells you which channel a build came from:
|
||||
|
||||
- `2026.807.0` — stable, published Aug 7 2026
|
||||
- `2026.807.0-beta.0` — beta promoted on Aug 7 2026
|
||||
- `2026.807.0-nightly.0` — nightly cut on Aug 7 2026
|
||||
- `2026.807.0-canary.4` — the fifth canary for the Aug 7 line
|
||||
|
||||
Each promotion republishes the exact source commit of the previous lane's
|
||||
build: a nightly shares its source SHA with a canary, and a beta with a
|
||||
nightly. The version dates the promotion, and the shared SHA is visible in
|
||||
the release job summaries and as git tags on the commit.
|
||||
|
||||
One quirk to be aware of: npm's semver ordering compares prerelease names
|
||||
alphabetically, so `-beta.N` sorts below `-canary.N`, which sorts below
|
||||
`-nightly.N` for the same base version. This never matters when installing by
|
||||
dist-tag (the recommended way), only if you write version ranges by hand.
|
||||
|
||||
## For maintainers
|
||||
|
||||
The publishing mechanics, promotion flow, and release checklist live in
|
||||
[`RELEASING.md`](RELEASING.md).
|
||||
+484
-375
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -267,7 +267,7 @@ pnpm paperclipai configure --section secrets
|
||||
Inline secret migration command:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
npx paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
|
||||
# direct database maintenance fallback
|
||||
pnpm secrets:migrate-inline-env --apply
|
||||
|
||||
@@ -87,8 +87,8 @@ Examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai onboard --yes
|
||||
pnpm paperclipai onboard --yes --bind lan
|
||||
pnpm paperclipai run --bind tailnet
|
||||
npx paperclipai onboard --yes --bind lan
|
||||
npx paperclipai run --bind tailnet
|
||||
```
|
||||
|
||||
`configure --section server` follows the same interactive behavior.
|
||||
|
||||
+53
-20
@@ -238,7 +238,7 @@ pnpm dev --authenticated-private
|
||||
Allow additional private hostnames (for example custom Tailscale hostnames):
|
||||
|
||||
```sh
|
||||
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
||||
npx paperclipai allowed-hostname dotta-macbook-pro
|
||||
```
|
||||
|
||||
## Test Commands
|
||||
@@ -433,6 +433,21 @@ Agent, project, environment, secret, skill, and workspace config edits are sampl
|
||||
|
||||
When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details.
|
||||
|
||||
## Workspace Git Scan Protection
|
||||
|
||||
Paperclip applies one process-wide scheduler to expensive host-side workspace Git enumeration, including changed-file browsing, runtime/finalization cleanliness guards, and adapter sandbox-sync snapshots. The scheduler defaults to two active scans and a bounded queue of 32. Identical scans of the same canonical worktree share one subprocess, while successful changed-file listings are cached for 10 seconds. Correctness-sensitive runtime guards bypass the result cache.
|
||||
|
||||
The cache intentionally trades up to a few seconds of changed-file freshness for stable server latency. The file browser retains an explicit refresh action, does not start its query while the panel or browser tab is hidden, and presents overloads as retryable failures rather than an empty workspace. A full queue returns `503` with code `workspace_git_scan_saturated`; a scan exceeding its wall-clock limit returns `504` with code `workspace_git_scan_timeout`. Both responses include `Retry-After: 1`.
|
||||
|
||||
Environment overrides:
|
||||
|
||||
- `PAPERCLIP_WORKSPACE_GIT_SCAN_CONCURRENCY` (default `2`, range `1`–`16`)
|
||||
- `PAPERCLIP_WORKSPACE_GIT_SCAN_QUEUE_CAPACITY` (default `32`, range `0`–`1024`)
|
||||
- `PAPERCLIP_WORKSPACE_GIT_SCAN_TIMEOUT_MS` (default `8000`, range `100`–`120000`)
|
||||
- `PAPERCLIP_WORKSPACE_GIT_SCAN_CACHE_TTL_MS` (default `10000`, range `0`–`60000`)
|
||||
|
||||
Structured `workspace_git_scan` logs expose the operation name, a non-reversible workspace-path hash, queue and execution durations, active/queued counts, cache and single-flight use, and terminal outcome. Saturation and timeout warnings are rate-limited so an overload does not create a second logging storm.
|
||||
|
||||
## Worktree-local Instances
|
||||
|
||||
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
|
||||
@@ -442,7 +457,7 @@ Instead, create a repo-local Paperclip config plus an isolated instance for the
|
||||
```sh
|
||||
paperclipai worktree init
|
||||
# or create the git worktree and initialize it in one step:
|
||||
pnpm paperclipai worktree:make paperclip-pr-432
|
||||
npx paperclipai worktree:make paperclip-pr-432
|
||||
```
|
||||
|
||||
This command:
|
||||
@@ -479,6 +494,7 @@ The default `worktree init` still seeds eagerly and writes `seed-complete` immed
|
||||
|
||||
- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: a present `seed-complete` marker or a missing `seed-pending` marker short-circuits it, so it is safe to call repeatedly and from concurrent processes. It reads the source instance from the `seed-pending` marker unless you pass `--from-config`.
|
||||
- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot, so `run` transparently seeds a lean worktree on first launch.
|
||||
- Managed git-worktree runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when a legacy workspace policy has no explicit runtime provision command and the worktree is still `seed-pending`. An explicitly configured runtime provision command always takes precedence.
|
||||
- Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned).
|
||||
|
||||
**Seed-pending guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose database is still `seed-pending` and points you at the fix:
|
||||
@@ -514,7 +530,7 @@ eval "$(paperclipai worktree env)"
|
||||
|
||||
### Worktree CLI Reference
|
||||
|
||||
**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
|
||||
**`npx paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
@@ -544,7 +560,7 @@ Repair an already-created repo-managed worktree and reseed its isolated instance
|
||||
|
||||
```sh
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
|
||||
pnpm paperclipai worktree init --force --seed-mode minimal \
|
||||
npx paperclipai worktree init --force --seed-mode minimal \
|
||||
--name PAP-884-ai-commits-component \
|
||||
--from-config ~/.paperclip/instances/default/config.json
|
||||
```
|
||||
@@ -553,7 +569,7 @@ That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, r
|
||||
|
||||
For an already-created worktree where you want the CLI to decide whether to rebuild missing worktree metadata or just reseed the isolated DB, use `worktree repair`.
|
||||
|
||||
**`pnpm paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
|
||||
**`npx paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
@@ -574,13 +590,14 @@ cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-i
|
||||
pnpm paperclipai worktree repair
|
||||
|
||||
# From the primary checkout, create or repair a linked worktree for a branch under .paperclip/worktrees/.
|
||||
# This command repairs the local checkout, so run the checked-out CLI through the direct-exec form.
|
||||
cd /path/to/paperclip
|
||||
pnpm paperclipai worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
```
|
||||
|
||||
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
|
||||
|
||||
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
||||
**`npx paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
@@ -598,7 +615,7 @@ Examples:
|
||||
```sh
|
||||
# From the main repo, reseed a worktree from the current default/master instance.
|
||||
cd /path/to/paperclip
|
||||
pnpm paperclipai worktree reseed \
|
||||
npx paperclipai worktree reseed \
|
||||
--from current \
|
||||
--to PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat \
|
||||
--seed-mode full \
|
||||
@@ -606,12 +623,12 @@ pnpm paperclipai worktree reseed \
|
||||
|
||||
# From inside a worktree, reseed it from the default instance config.
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
pnpm paperclipai worktree reseed \
|
||||
npx paperclipai worktree reseed \
|
||||
--from-instance default \
|
||||
--seed-mode full
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||
**`npx paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
@@ -630,12 +647,12 @@ pnpm paperclipai worktree reseed \
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree:make paperclip-pr-432
|
||||
pnpm paperclipai worktree:make my-feature --start-point origin/main
|
||||
pnpm paperclipai worktree:make experiment --no-seed
|
||||
npx paperclipai worktree:make paperclip-pr-432
|
||||
npx paperclipai worktree:make my-feature --start-point origin/main
|
||||
npx paperclipai worktree:make experiment --no-seed
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
|
||||
**`npx paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
@@ -647,13 +664,29 @@ Examples:
|
||||
```sh
|
||||
pnpm paperclipai worktree env
|
||||
pnpm paperclipai worktree env --json
|
||||
eval "$(pnpm paperclipai worktree env)"
|
||||
eval "$(npx paperclipai worktree env)"
|
||||
```
|
||||
|
||||
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
|
||||
|
||||
Heavier setup that is only needed by a managed runtime service can use `workspaceStrategy.runtimeProvisionCommand`. Paperclip runs this command lazily before spawning the first service in a start batch, serializes concurrent provisioning for the same workspace, and records the attempt as `workspace_runtime_provision`. The command receives the same workspace environment as `provisionCommand` and should be idempotent because later service-start batches invoke it again.
|
||||
|
||||
Managed runtime control actions (`start`, `stop`, `restart`, and job `run`) are mutually exclusive per execution workspace. An overlapping control is rejected with `409 workspace_runtime_control_in_progress` instead of racing the active operation, and authorization is still checked first, so the conflict never widens who may control a workspace.
|
||||
|
||||
Every managed control reaches a terminal operation state. Each one stamps the owning server process and pid on its `workspace_operations` row and heartbeats while it runs, and each one carries a wall-clock ceiling (30 minutes for lifecycle controls, 4 hours for workspace jobs) so a hung provider or listener fails the operation rather than leaving it active. When a start fails part-way, Paperclip tears the workspace's runtime services down through the ordinary stop path and records a stopped desired state, so the lane is retryable and a startup reconcile will not resurrect a service that never came up.
|
||||
|
||||
Recovery of stranded controls is bounded and cannot steal a live operation. A `running` control is only terminalized when its owning process is gone, when the owning request in this process no longer exists, or after 60 seconds without a heartbeat; the terminalizing write is a compare-and-swap on `updated_at`, so an owner that heartbeats concurrently keeps its operation. Recovery runs on server startup and before each managed control, appends reconciliation evidence to the workspace-operation log, and stays inside the requested workspace's scope.
|
||||
|
||||
Readiness probes and port allocation are bounded for the same reason. Each HTTP readiness probe is aborted after at most 5 seconds (never past the service's readiness budget), so a foreign listener that accepts a connection but never answers cannot park a start forever. Auto-allocated loopback ports are reserved in-process for the duration of a start and re-checked for a live owner, and a configured port already claimed by another in-flight start fails that start terminally — so two isolated workspaces asking for the same app/HMR pair either get distinct healthy ports or one fails cleanly and retryably.
|
||||
|
||||
Beyond that in-flight guard, `start`, `stop`, and `restart` also take a durable exclusivity lease on the execution workspace (`execution_workspace_runtime_leases`). The lease is keyed by execution workspace and owned by the controlling issue (or, when a run has no issue in scope, by the run or agent), so it survives across calls, heartbeats, and server processes. The owning issue can keep operating; a different issue or run is rejected with `409 workspace_runtime_lease_conflict` before any workspace operation is recorded and before any runtime service is touched. Board/operator actions bypass the lease entirely and never take the lane away from an agent.
|
||||
|
||||
Lease recovery is bounded and explicit. Another issue may reclaim the lane once the owner becomes ineligible — the owning issue reaches a terminal status, is hidden, or is deleted; the owning run reaches a terminal status — or once the lease's 30-minute TTL elapses without the owner touching it. Archiving the execution workspace releases the lease outright. Conflict responses carry the owning issue/run ids and the lease expiry so an operator can tell who holds the lane.
|
||||
|
||||
For Tailscale HTTPS exposure, readiness includes stable listener-ownership checks for every requested loopback port (the app and, when configured, its Vite HMR companion). Each listener must belong to the spawned managed process group; an unrelated listener that races onto either reserved port fails the start closed before the broker is asked to expose it.
|
||||
|
||||
In Vite middleware mode, Paperclip gives HMR a dedicated HTTP server bound to the managed runtime's loopback host. The browser still derives the HMR hostname from the public HTTPS page, so listener containment does not break remote hot reload.
|
||||
|
||||
## App-Shipped Skills Catalog
|
||||
|
||||
The Paperclip app ships a curated catalog of company skills out of the box. The
|
||||
@@ -876,15 +909,15 @@ Paperclip CLI now includes client-side control-plane commands in addition to set
|
||||
Quick examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai issue list --company-id <company-id>
|
||||
pnpm paperclipai issue create --company-id <company-id> --title "Investigate checkout conflict"
|
||||
pnpm paperclipai issue update <issue-id> --status in_progress --comment "Started triage"
|
||||
npx paperclipai issue list --company-id <company-id>
|
||||
npx paperclipai issue create --company-id <company-id> --title "Investigate checkout conflict"
|
||||
npx paperclipai issue update <issue-id> --status in_progress --comment "Started triage"
|
||||
```
|
||||
|
||||
Set defaults once with context profiles:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai context set --api-base http://localhost:3100 --company-id <company-id>
|
||||
npx paperclipai context set --api-base http://localhost:3100 --company-id <company-id>
|
||||
```
|
||||
|
||||
Then run commands without repeating flags:
|
||||
@@ -968,4 +1001,4 @@ Networking behavior for this smoke script:
|
||||
|
||||
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
|
||||
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
|
||||
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `pnpm paperclipai allowed-hostname host.docker.internal` and restart Paperclip
|
||||
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `npx paperclipai allowed-hostname host.docker.internal` and restart Paperclip
|
||||
@@ -79,9 +79,9 @@ The UI prompt points Hermes at the same machine-readable onboarding endpoints:
|
||||
For CLI-driven setup, create and inspect the invite directly:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai invite create --company-id <company-id> --payload-json '{"requestType":"agent"}'
|
||||
pnpm paperclipai invite show <token>
|
||||
pnpm paperclipai invite onboarding:text <token>
|
||||
npx paperclipai invite create --company-id <company-id> --payload-json '{"requestType":"agent"}'
|
||||
npx paperclipai invite show <token>
|
||||
npx paperclipai invite onboarding:text <token>
|
||||
```
|
||||
|
||||
Hermes should submit a join request with `requestType: "agent"` and
|
||||
@@ -119,14 +119,14 @@ After Hermes submits the join request:
|
||||
2. Approve it from the board UI, or use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai join list --company-id <company-id> --status pending_approval
|
||||
pnpm paperclipai join approve <request-id> --company-id <company-id>
|
||||
npx paperclipai join list --company-id <company-id> --status pending_approval
|
||||
npx paperclipai join approve <request-id> --company-id <company-id>
|
||||
```
|
||||
|
||||
3. Hermes claims the one-time agent API key:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai join claim-key <request-id> --claim-secret <secret>
|
||||
npx paperclipai join claim-key <request-id> --claim-secret <secret>
|
||||
```
|
||||
|
||||
4. Store the claimed Paperclip key in Hermes runtime state or secrets. The claim
|
||||
@@ -182,7 +182,7 @@ Use these entry points depending on who is driving setup:
|
||||
onboarding prompt.
|
||||
- Invite API: `GET /api/invites/:token/onboarding.txt` for the generated
|
||||
llm.txt-style setup instructions.
|
||||
- CLI invite flow: `pnpm paperclipai invite create`, `invite show`,
|
||||
- CLI invite flow: `npx paperclipai invite create`, `invite show`,
|
||||
`invite onboarding:text`, `join approve`, and `join claim-key`.
|
||||
- Smoke helpers: `pnpm smoke:hermes-gateway-e2e` for fresh-state Docker
|
||||
verification and `pnpm smoke:hermes-gateway-join` for an already-running
|
||||
|
||||
@@ -113,9 +113,10 @@ Goal:
|
||||
|
||||
## 4. Create GitHub Environments
|
||||
|
||||
Create two environments in the GitHub repository:
|
||||
Create three environments in the GitHub repository:
|
||||
|
||||
- `npm-canary`
|
||||
- `npm-beta`
|
||||
- `npm-stable`
|
||||
|
||||
Path:
|
||||
@@ -141,6 +142,35 @@ Reasoning:
|
||||
- every push to `master` should be able to publish a canary automatically
|
||||
- no human approval should be required for canaries
|
||||
|
||||
The scheduled nightly lane also publishes under `npm-canary`: it is the same
|
||||
trust level (fully automated, no human gate), its runs execute on `master` so
|
||||
the branch rule is satisfied, and reusing the environment means the nightly
|
||||
lane required no new environments and no npm trusted-publisher changes
|
||||
(publishing still happens from `release.yml`, see section 2.2).
|
||||
|
||||
## 5.1. Configure `npm-beta`
|
||||
|
||||
Recommended settings for `npm-beta`:
|
||||
|
||||
- environment name: `npm-beta`
|
||||
- required reviewers: at least one maintainer
|
||||
- prevent self-review: enabled when your team size allows it
|
||||
- wait timer: none
|
||||
- deployment branches and tags:
|
||||
- selected branches only
|
||||
- allow `master`
|
||||
|
||||
Reasoning:
|
||||
|
||||
- beta promotions are deliberate human decisions; the required reviewer on
|
||||
this environment is the promotion gate
|
||||
- create this environment before the first `channel: beta` dispatch. If the
|
||||
workflow runs first, GitHub auto-creates the environment with no
|
||||
protection rules, and that first beta would publish without approval
|
||||
|
||||
Like nightly, beta publishing lives in `release.yml`, so no npm
|
||||
trusted-publisher changes are needed (see section 2.2).
|
||||
|
||||
## 6. Configure `npm-stable`
|
||||
|
||||
Recommended settings for `npm-stable`:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Release Checklist
|
||||
|
||||
The release captain's checklist for every lane. The mechanics live in
|
||||
[`RELEASING.md`](RELEASING.md); the user-facing channel guide is
|
||||
[`CHANNELS.md`](CHANNELS.md).
|
||||
|
||||
## Canary (automatic, every `master` push)
|
||||
|
||||
- [ ] the push's `Release` run is green (verify + publish)
|
||||
- [ ] `npm view paperclipai@canary version` matches the expected canary
|
||||
- [ ] Docker `:canary` updated (the same push's `Docker` run)
|
||||
- [ ] a canary publish failure is a release-infra regression — fix it before
|
||||
trusting later promotions
|
||||
|
||||
## Nightly (automatic, 09:00 UTC)
|
||||
|
||||
- [ ] the scheduled run selected the newest green canary, or skipped with a
|
||||
job-summary reason (no new candidate / already shipped / red smoke)
|
||||
- [ ] the release smoke suite passed against the exact candidate canary
|
||||
before anything published
|
||||
- [ ] `npm view paperclipai@nightly version` shows the new `-nightly.N`
|
||||
- [ ] `nightly/v*` tag pushed; `:nightly` and `:nightly-cloud` images built
|
||||
- [ ] on a tag-push rejection (workflows-permission error), follow the
|
||||
recovery commands in the job summary
|
||||
|
||||
To force a nightly: dispatch `release.yml` with `channel: nightly`
|
||||
(optional exact canary in `source_version`; `dry_run` to preview).
|
||||
|
||||
## Beta (manual promotion)
|
||||
|
||||
Happy path:
|
||||
|
||||
- [ ] pick the nightly to promote (empty `source_version` selects the newest)
|
||||
- [ ] dispatch `release.yml` with `channel: beta`
|
||||
- [ ] approve the `npm-beta` environment gate
|
||||
- [ ] `npm view paperclipai@beta version` shows the new `-beta.N`
|
||||
- [ ] `beta/v*` tag pushed; `:beta` and `:beta-cloud` images built
|
||||
- [ ] post-publish smoke (`smoke_beta`) is green
|
||||
- [ ] `draft_stable_notes` pushed `release-notes/v<beta-version>`; open the
|
||||
notes PR from the job-summary link
|
||||
- [ ] during the soak: edit the notes PR into release voice and merge it
|
||||
(the stable promotion reads `releases/beta/v<beta-version>.md` from
|
||||
`master`)
|
||||
|
||||
Fix path (cherry-picked candidate):
|
||||
|
||||
- [ ] cut `candidate/beta-<target>` from the chosen nightly's source commit
|
||||
- [ ] cherry-pick only the required fixes; push the branch
|
||||
- [ ] dispatch `channel: beta` with `candidate_branch`
|
||||
- [ ] confirm the job summary records the cherry-picked commits and that
|
||||
full verification ran on the candidate head
|
||||
- [ ] after shipping: reconcile the fixes to `master`, delete the branch
|
||||
|
||||
## Stable (manual promotion)
|
||||
|
||||
- [ ] pick the beta to promote; its source commit is `source_ref`
|
||||
- [ ] the beta has soaked ≥ 3 days with no open beta-blocker issues
|
||||
- [ ] the beta's notes PR (`releases/beta/v<beta-version>.md`) is merged on
|
||||
`master` — preflight fails, before the approval gate, without it
|
||||
- [ ] dispatch `release.yml` with `channel: stable` (a dry run first shows
|
||||
the resolved version and soak state without publishing)
|
||||
- [ ] approve the `npm-stable` environment gate
|
||||
- [ ] `npm view paperclipai version` (dist-tag `latest`) shows the stable
|
||||
- [ ] `vYYYY.MDD.P` tag pushed; GitHub Release created; `:latest` and the
|
||||
versioned Docker tags built
|
||||
- [ ] if the soak gate was bypassed, `skip_soak_justification` carries a
|
||||
real written reason (it lands in the job summary)
|
||||
- [ ] open and merge the canonicalization PR
|
||||
(`release-notes/v<version>-canonicalize`) so the notes land at
|
||||
`releases/vYYYY.MDD.P.md`
|
||||
|
||||
Fix path: `candidate/release-<target>` from the beta's source commit; the
|
||||
soak gate will demand a justification because the exact bits were not
|
||||
soaked — write one that stands on its own. A candidate branch carries its
|
||||
own `releases/vYYYY.MDD.P.md` (preflight prefers source-tree notes), so
|
||||
author the notes as a commit on the candidate.
|
||||
|
||||
## After any incomplete run
|
||||
|
||||
The failure playbooks in [`RELEASING.md`](RELEASING.md) cover: red canary,
|
||||
skipped or failed nightly, a beta that looks bad during soak, partial
|
||||
stable releases, broken `latest`, and rejected tag pushes. Every publish
|
||||
job's summary names what completed and what remains.
|
||||
+254
-13
@@ -5,9 +5,18 @@ Maintainer runbook for shipping Paperclip across npm, GitHub, and the website-fa
|
||||
The release model is now commit-driven:
|
||||
|
||||
1. Every push to `master` publishes a canary automatically.
|
||||
2. Stable releases are manually promoted from a chosen tested commit or canary tag.
|
||||
3. Stable release notes live in `releases/vYYYY.MDD.P.md`.
|
||||
4. Only stable releases get GitHub Releases.
|
||||
2. Once a night, the newest master commit with a green canary publish is
|
||||
smoke-tested and republished as the nightly.
|
||||
3. Betas are manual, human-approved promotions of a chosen nightly.
|
||||
4. Stable releases promote a beta that has soaked for at least 3 days
|
||||
(bypass requires a written justification).
|
||||
5. Stable release notes live in `releases/vYYYY.MDD.P.md`. They are
|
||||
drafted automatically when a beta is published (as
|
||||
`releases/beta/v<beta-version>.md` on `master`), edited during the
|
||||
soak, and moved to the versioned name after the stable ships.
|
||||
6. Only stable releases get GitHub Releases.
|
||||
|
||||
The user-facing guide to the channels is [`CHANNELS.md`](CHANNELS.md).
|
||||
|
||||
## Versioning Model
|
||||
|
||||
@@ -15,12 +24,20 @@ Paperclip uses calendar versions that still fit semver syntax:
|
||||
|
||||
- stable: `YYYY.MDD.P`
|
||||
- canary: `YYYY.MDD.P-canary.N`
|
||||
- nightly: `YYYY.MDD.P-nightly.N`
|
||||
- beta: `YYYY.MDD.P-beta.N`
|
||||
|
||||
Examples:
|
||||
|
||||
- first stable on March 18, 2026: `2026.318.0`
|
||||
- second stable on March 18, 2026: `2026.318.1`
|
||||
- fourth canary for the `2026.318.1` line: `2026.318.1-canary.3`
|
||||
- first nightly cut on March 18, 2026: `2026.318.1-nightly.0`
|
||||
- first beta promoted on March 18, 2026: `2026.318.1-beta.0`
|
||||
|
||||
A promotion republishes the exact source commit of the previous lane's build
|
||||
(canary → nightly → beta); the version dates the promotion, not the source
|
||||
build.
|
||||
|
||||
Important constraints:
|
||||
|
||||
@@ -41,16 +58,32 @@ Every stable release has four separate surfaces:
|
||||
|
||||
A stable release is done only when all four surfaces are handled.
|
||||
|
||||
Canaries only cover the first two surfaces plus an internal traceability tag.
|
||||
Canaries, nightlies, and betas only cover the first two surfaces plus an
|
||||
internal traceability tag.
|
||||
|
||||
## Core Invariants
|
||||
|
||||
- canaries publish from `master`
|
||||
- stables publish from an explicitly chosen source ref
|
||||
- nightlies republish a commit that already shipped a canary (the commit must
|
||||
carry a `canary/v*` tag), and only after the release smoke suite passes
|
||||
against that exact published canary
|
||||
- betas republish a commit that already shipped a nightly (the commit must
|
||||
carry a `nightly/v*` tag), behind the `npm-beta` approval gate, and the
|
||||
published beta is re-smoked
|
||||
- stables publish from an explicitly chosen source ref, which must have
|
||||
shipped as a beta at least 3 days earlier unless a written justification
|
||||
is provided
|
||||
- tags point at the original source commit, not a generated release commit
|
||||
- stable notes are always `releases/vYYYY.MDD.P.md`
|
||||
- canaries never create GitHub Releases
|
||||
- canaries never require changelog generation
|
||||
- stable notes are always `releases/vYYYY.MDD.P.md` in the end state: a
|
||||
promoted beta's notes are drafted and edited at
|
||||
`releases/beta/v<beta-version>.md` on `master` during the soak (the
|
||||
promoted commit cannot carry a file named for a promotion date that was
|
||||
unknown when it was created), and a post-stable canonicalization PR
|
||||
moves them to the versioned name
|
||||
- canaries, nightlies, and betas never create GitHub Releases
|
||||
- canaries, nightlies, and betas never require changelog generation
|
||||
- Docker `:latest` moves only on stable releases; master builds publish
|
||||
`:canary`, nightly builds `:nightly`, and beta builds `:beta`
|
||||
|
||||
## TL;DR
|
||||
|
||||
@@ -78,6 +111,98 @@ npx paperclipai@canary onboard
|
||||
npx paperclipai@canary onboard --data-dir "$(mktemp -d /tmp/paperclip-canary.XXXXXX)"
|
||||
```
|
||||
|
||||
### Nightly
|
||||
|
||||
A scheduled job in [`.github/workflows/release.yml`](../.github/workflows/release.yml)
|
||||
runs once a night at 09:00 UTC.
|
||||
|
||||
It:
|
||||
|
||||
- selects the newest commit on `master` that carries a `canary/v*` tag (the
|
||||
tag is pushed only after a successful canary publish, so it is the
|
||||
green-publish signal)
|
||||
- skips with a job-summary reason when there is no new candidate or the
|
||||
candidate already shipped as a nightly
|
||||
- runs the release smoke suite ([`release-smoke.yml`](../.github/workflows/release-smoke.yml))
|
||||
against that exact published canary version — red smoke means no nightly
|
||||
tonight
|
||||
- republishes the same source commit as `YYYY.MDD.P-nightly.N` under the npm
|
||||
dist-tag `nightly` (the commit was already verified by its canary run, so
|
||||
verification is not repeated)
|
||||
- creates and pushes the git tag `nightly/vYYYY.MDD.P-nightly.N`
|
||||
- dispatches [`docker.yml`](../.github/workflows/docker.yml) at that tag to
|
||||
publish the `:nightly` images
|
||||
|
||||
To force a nightly outside the schedule (recovery, or promoting a specific
|
||||
canary), dispatch `release.yml` with `channel: nightly`. Leave
|
||||
`source_version` empty for automatic selection, or set it to an exact
|
||||
canary version. `dry_run: true` previews the publish and skips smoke, the tag
|
||||
push, and the Docker dispatch.
|
||||
|
||||
Users install nightlies with:
|
||||
|
||||
```bash
|
||||
npx paperclipai@nightly onboard
|
||||
```
|
||||
|
||||
### Beta
|
||||
|
||||
Betas are manual promotions. Dispatch
|
||||
[`release.yml`](../.github/workflows/release.yml) with `channel: beta`.
|
||||
|
||||
- leave `source_version` empty to promote the newest nightly, or set it to an
|
||||
exact nightly version such as `2026.807.0-nightly.0`
|
||||
- the selection job resolves the nightly's source commit and fails loudly if
|
||||
it does not exist or already shipped as a beta
|
||||
- the publish waits for approval in the **`npm-beta` environment** — its
|
||||
required reviewers are the promotion gate
|
||||
- promotions run the release tooling of the source commit, so the source
|
||||
nightly must postdate the beta channel's introduction; the selection job
|
||||
rejects older sources with a clear error (in practice every nightly cut
|
||||
after the beta tooling merged qualifies)
|
||||
- the same commit is republished as `YYYY.MDD.P-beta.N` under the npm
|
||||
dist-tag `beta`, tagged `beta/vYYYY.MDD.P-beta.N`, and `docker.yml` is
|
||||
dispatched at that tag to publish the `:beta` images
|
||||
- after publishing, the release smoke suite runs against the exact published
|
||||
beta version as verification
|
||||
- a `draft_stable_notes` job also generates the eventual stable's notes
|
||||
skeleton — `releases/beta/v<beta-version>.md`, grouped from
|
||||
`git log <last-stable-tag>..<source-commit>` — and force-pushes it to the
|
||||
machine-owned `release-notes/v<beta-version>` branch. Open the PR from
|
||||
the job-summary link (a human opens it so CI runs) and edit the notes
|
||||
during the soak; the stable promotion reads the merged file from
|
||||
`master`
|
||||
- `dry_run: true` previews the publish and skips the tag push, Docker
|
||||
dispatch, and post-publish smoke
|
||||
|
||||
Users install betas with:
|
||||
|
||||
```bash
|
||||
npx paperclipai@beta onboard
|
||||
```
|
||||
|
||||
#### Beta fix path: candidate branches
|
||||
|
||||
When one or two targeted fixes are needed before beta and waiting for the
|
||||
next nightly (or absorbing a whole day of `master`) is wrong, build the beta
|
||||
from a short-lived candidate branch:
|
||||
|
||||
1. cut `candidate/beta-<target>` from the chosen nightly's source commit
|
||||
(for example `candidate/beta-2026.811.0`)
|
||||
2. cherry-pick only the required fix commits onto it and push the branch
|
||||
3. dispatch `release.yml` with `channel: beta` and `candidate_branch:
|
||||
candidate/beta-<target>`
|
||||
4. selection validates the branch name, rejects heads that already shipped
|
||||
as a beta, records the cherry-picked commits in the job summary, and the
|
||||
head runs **full verification** before publishing (it never went through
|
||||
a canary or nightly)
|
||||
5. after the beta ships, land the fixes on `master` normally and delete the
|
||||
candidate branch
|
||||
|
||||
Use this sparingly: the happy path is promoting a nightly. A candidate build
|
||||
has its own `-beta.N` identity and is never pretended to be the nightly it
|
||||
was cut from.
|
||||
|
||||
### Stable
|
||||
|
||||
Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the Actions tab with the manual `workflow_dispatch` inputs.
|
||||
@@ -86,20 +211,44 @@ Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the
|
||||
|
||||
Inputs:
|
||||
|
||||
- `channel`
|
||||
- `stable` (the default) for a stable release; `beta` and `nightly` run
|
||||
those lanes instead (see above)
|
||||
- `source_ref`
|
||||
- commit SHA, branch, or tag
|
||||
- `stable_date`
|
||||
- optional UTC date override in `YYYY-MM-DD`
|
||||
- enter a date like `2026-03-18`, not a version like `2026.318.0`
|
||||
- `skip_soak_justification`
|
||||
- written reason for releasing a stable whose source has not soaked as a
|
||||
beta for 3 days; leave empty for normal releases
|
||||
- `dry_run`
|
||||
- preview only when true
|
||||
|
||||
The stable preflight enforces the beta soak: the source commit must carry a
|
||||
`beta/v*` tag whose npm publish time is at least 3 days old. If it is not,
|
||||
the run fails unless `skip_soak_justification` is provided; the justification
|
||||
is echoed into the job summary. Dry runs report soak state without blocking.
|
||||
|
||||
For a cherry-picked stable (the release fix path), cut
|
||||
`candidate/release-<target>` from the chosen beta's source commit,
|
||||
cherry-pick the required fixes, push the branch, and use it as
|
||||
`source_ref`. The candidate head carries no `beta/v*` tag, so the soak gate
|
||||
requires `skip_soak_justification` — that is deliberate: the exact bits were
|
||||
not soaked, and the justification is the recorded trade-off. Reconcile the
|
||||
fixes back to `master` and delete the branch after shipping.
|
||||
|
||||
Before running stable:
|
||||
|
||||
1. pick the canary commit or tag you trust
|
||||
2. resolve the target stable version with `./scripts/release.sh stable --date "$(date +%F)" --print-version`
|
||||
3. create or update `releases/vYYYY.MDD.P.md` on that source ref
|
||||
4. run the stable workflow from that source ref
|
||||
1. pick the beta you are promoting (its source commit is the `source_ref`)
|
||||
2. confirm the beta has soaked for 3 days with no open blockers
|
||||
3. resolve the target stable version with `./scripts/release.sh stable --date "$(date +%F)" --print-version`
|
||||
4. make sure the notes PR from the beta's draft branch
|
||||
(`release-notes/v<beta-version>`, adding
|
||||
`releases/beta/v<beta-version>.md`) is merged on `master` — or, for
|
||||
candidate builds, that the candidate branch itself carries
|
||||
`releases/vYYYY.MDD.P.md`
|
||||
5. run the stable workflow from that source ref
|
||||
|
||||
Example:
|
||||
|
||||
@@ -111,9 +260,36 @@ The workflow:
|
||||
|
||||
- re-verifies the exact source ref
|
||||
- computes the next stable patch slot for the chosen UTC date
|
||||
- resolves the release notes in preflight: `releases/vYYYY.MDD.P.md` at
|
||||
the source commit (the candidate fix path) takes precedence, otherwise
|
||||
`releases/beta/v<beta-version>.md` on `master` (a promoted beta). When
|
||||
neither exists the run fails before the `npm-stable` approval gate with
|
||||
the missing path named
|
||||
- publishes `YYYY.MDD.P` under npm dist-tag `latest`
|
||||
- creates git tag `vYYYY.MDD.P`
|
||||
- creates or updates the GitHub Release from `releases/vYYYY.MDD.P.md`
|
||||
- dispatches [`docker.yml`](../.github/workflows/docker.yml) at that tag to
|
||||
publish `:latest` and the versioned stable images
|
||||
- creates or updates the GitHub Release from the resolved notes file
|
||||
- for master-side beta notes, pushes a `release-notes/v<version>-canonicalize`
|
||||
branch that `git mv`s them to `releases/vYYYY.MDD.P.md` — open and merge
|
||||
its PR to restore the canonical layout
|
||||
|
||||
## Docker Image Tags
|
||||
|
||||
[`docker.yml`](../.github/workflows/docker.yml) publishes both the self-hosted
|
||||
image and the `-cloud` variant with the same lane mapping:
|
||||
|
||||
| Build ref | Tags |
|
||||
| --- | --- |
|
||||
| `master` push | `:canary`, `:sha-<short>` |
|
||||
| `nightly/v*` tag | `:nightly`, `:sha-<short>` |
|
||||
| `beta/v*` tag | `:beta`, `:sha-<short>` |
|
||||
| `v*` tag (stable) | `:latest`, `:YYYY.MDD.P`, `:YYYY.MDD`, `:sha-<short>` |
|
||||
|
||||
Lane tags are pushed by release workflows using `GITHUB_TOKEN`, and GitHub
|
||||
suppresses push-triggered workflow runs for those pushes. The release jobs
|
||||
therefore dispatch `docker.yml` explicitly at the new tag ref; the tag
|
||||
mapping keys off `github.ref` either way.
|
||||
|
||||
## Local Commands
|
||||
|
||||
@@ -123,6 +299,24 @@ The workflow:
|
||||
./scripts/release.sh canary --dry-run
|
||||
```
|
||||
|
||||
### Preview a nightly locally
|
||||
|
||||
Requires HEAD to be a commit that already shipped a canary (it must carry a
|
||||
`canary/v*` tag):
|
||||
|
||||
```bash
|
||||
./scripts/release.sh nightly --dry-run
|
||||
```
|
||||
|
||||
### Preview a beta locally
|
||||
|
||||
Requires HEAD to be a commit that already shipped a nightly (it must carry a
|
||||
`nightly/v*` tag):
|
||||
|
||||
```bash
|
||||
./scripts/release.sh beta --dry-run
|
||||
```
|
||||
|
||||
### Preview a stable locally
|
||||
|
||||
```bash
|
||||
@@ -147,6 +341,12 @@ Stable changelog files live at:
|
||||
|
||||
Canaries do not get changelog files.
|
||||
|
||||
The `draft_stable_notes` job seeds a deterministic skeleton (grouped
|
||||
commit subjects) on the `release-notes/v<beta-version>` branch at beta
|
||||
publish; the flows below turn that skeleton into narrative release notes
|
||||
during the soak. Run them against the draft branch's
|
||||
`releases/beta/v<beta-version>.md` and push to the notes PR.
|
||||
|
||||
Recommended local generation flow:
|
||||
|
||||
```bash
|
||||
@@ -185,9 +385,15 @@ Automated browser smoke is also available:
|
||||
|
||||
```bash
|
||||
gh workflow run release-smoke.yml -f paperclip_version=canary
|
||||
gh workflow run release-smoke.yml -f paperclip_version=nightly
|
||||
gh workflow run release-smoke.yml -f paperclip_version=beta
|
||||
gh workflow run release-smoke.yml -f paperclip_version=latest
|
||||
```
|
||||
|
||||
The nightly lane runs this same suite automatically against its candidate
|
||||
before publishing, and the beta lane runs it against the published beta as
|
||||
post-publish verification.
|
||||
|
||||
Minimum checks:
|
||||
|
||||
- `npx paperclipai@canary onboard` installs
|
||||
@@ -224,6 +430,40 @@ Instead:
|
||||
3. wait for the next automatic canary
|
||||
4. rerun smoke testing
|
||||
|
||||
### If the nightly skipped or failed
|
||||
|
||||
A skipped nightly is working as designed — the job summary names the reason
|
||||
(no new green candidate, candidate already shipped, or red smoke). Nothing was
|
||||
published, so there is nothing to clean up.
|
||||
|
||||
To recover after fixing the cause, either wait for the next scheduled run or
|
||||
force one: dispatch `release.yml` with `channel: nightly` (optionally pinning
|
||||
`source_version` to a specific canary).
|
||||
|
||||
If the nightly published to npm but the tag push or Docker dispatch failed,
|
||||
push the `nightly/v*` tag manually and run `docker.yml` at that tag.
|
||||
|
||||
### If a tag push is rejected with a workflows-permission error
|
||||
|
||||
GITHUB_TOKEN may not create refs that point at commits which modify workflow
|
||||
files when the run was started by dispatch or schedule (push-triggered runs
|
||||
are exempt, which is why canary tags on the same commit succeed). The npm
|
||||
publish is already complete and correct when this happens. The failed job's
|
||||
summary contains the exact recovery commands: create and push the tag with
|
||||
maintainer credentials, then dispatch `docker.yml` at the tag (and for
|
||||
stable, run `create-github-release.sh`). This only occurs when a
|
||||
release-infrastructure commit itself becomes a promotion source.
|
||||
|
||||
### If a beta looks bad during soak
|
||||
|
||||
Do not promote it to stable. Fix forward: land the fix on `master`, let it
|
||||
ship through canary and nightly, and promote a new beta. The soak clock
|
||||
starts over for the new beta.
|
||||
|
||||
If the published beta is actively harmful to beta users, move the `beta`
|
||||
dist-tag back to the previous beta version with `npm dist-tag add` per
|
||||
package, and re-point the `:beta` Docker tags at the previous beta's images.
|
||||
|
||||
### If stable npm publish succeeds but tag push or GitHub release creation fails
|
||||
|
||||
This is a partial release. npm is already live.
|
||||
@@ -252,5 +492,6 @@ Then fix forward with a new stable release.
|
||||
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh)
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh)
|
||||
- [`doc/RELEASE-CHECKLIST.md`](RELEASE-CHECKLIST.md)
|
||||
- [`doc/PUBLISHING.md`](PUBLISHING.md)
|
||||
- [`doc/RELEASE-AUTOMATION-SETUP.md`](RELEASE-AUTOMATION-SETUP.md)
|
||||
+91
-25
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
|
||||
| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
|
||||
| Communication | Tasks + comments only (no separate chat system) |
|
||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree and resolve only eligible task-level plan confirmations; it is not board authority, active-run output monitoring, or general liveness recovery. |
|
||||
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
|
||||
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
|
||||
@@ -258,6 +258,10 @@ Invariants:
|
||||
- single assignee only
|
||||
- task must trace to company goal chain via `goal_id`, `parent_id`, or project-goal linkage
|
||||
- `in_progress` requires assignee
|
||||
- an `in_review -> done | cancelled` verdict is authorized against the current review policy while the issue row is locked; a policy change in the same request or a concurrent request cannot relax that verdict gate
|
||||
- accepting or rejecting the review-confirmation interaction locks the issue row before resolving the interaction and reauthorizes against the current review policy in that transaction
|
||||
- while a restrictive review policy is stored, changing it requires an actor who is allowed by that row-locked policy
|
||||
- the transition into `in_review` and its requester activity record commit atomically, including transitions without an explicit review-interaction binding
|
||||
- terminal states: `done | cancelled`
|
||||
|
||||
## 7.7 `issue_comments`
|
||||
@@ -556,7 +560,7 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
|
||||
| Set company budget | yes | no |
|
||||
| Set subordinate budget | yes | yes (manager subtree only) |
|
||||
| Manage responsible user's inbox state | yes | yes (default-open policy) |
|
||||
| Manage another user's inbox state | yes | scoped `inbox:manage` grant |
|
||||
| Manage another user's inbox state | yes | saved target-user opt-in or scoped `inbox:manage` grant |
|
||||
| Set work-object visibility (issue/project) | no | no (pro gate) |
|
||||
|
||||
### 9.3.1 Shared default-open issue writes
|
||||
@@ -580,10 +584,11 @@ may wake the target assignee, including an explicit `resume: true` comment on a
|
||||
the normal agent rewake throttle; comment presentation cannot give it human
|
||||
wake privileges. Agent issue comments and updates require a persisted heartbeat
|
||||
run bound to the authenticated agent and company; missing, invalid, or mismatched
|
||||
run context fails closed before mutation. A run may attempt at most 20 cross-issue comments or issue
|
||||
updates across the shared counter. The server records each attempt with its
|
||||
source issue, target issue, run, count, and rollout mode, and fails closed with
|
||||
the cap in the error once enforcement is active. Assignee self-comments do not
|
||||
run context fails closed before mutation. A run may attempt at most 20 cross-issue comments, issue
|
||||
updates, or issue-thread interaction resolutions across one shared counter. The
|
||||
server records each attempt with its source issue, target issue, run, count, and
|
||||
rollout mode, and fails closed with the cap in the error once enforcement is
|
||||
active. Writes to the run's own source issue are not counted. Assignee self-comments do not
|
||||
wake the assignee, and a non-assignee comment cannot mint a mention grant.
|
||||
|
||||
Agent-authored issue comments persist the responsible user derived from the
|
||||
@@ -624,7 +629,7 @@ The approved term set is:
|
||||
| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels |
|
||||
| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation |
|
||||
| Company skills | Open to authenticated company agents; core enforces invariants and any stored restriction policy | Paperclip EE policy editor, protected-skill controls, presets, simulation, and policy audit UX |
|
||||
| Inbox management | Responsible agent may archive/unarchive its responsible user's Mine items under a default-open user policy; cross-user access requires `inbox:manage`; all mutations are audited | Policy administration UX, organization presets, simulations, bulk controls, and richer audit/reporting surfaces |
|
||||
| Inbox management | Responsible agent may archive/unarchive its responsible user's Mine items under a default-open user policy; explicit cross-user access requires saved target-user opt-in or `inbox:manage`; all mutations are audited | Policy administration UX, organization presets, simulations, bulk controls, and richer audit/reporting surfaces |
|
||||
| Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows |
|
||||
|
||||
## 9.7 Recommended first-slice implementation order
|
||||
@@ -656,6 +661,66 @@ approval is created. The legacy fields `protectedAgent.requiresApproval` and
|
||||
aliases for the same hard block, but API denial copy must describe the block and
|
||||
administrator remediation rather than promising a nonexistent approval step.
|
||||
|
||||
### 9.8.1 Issue-thread interaction resolver contract
|
||||
|
||||
Issue-thread interactions are coordination records, not grants of authority. Every
|
||||
interaction kind defaults to resolver policy `anyone` when the create request omits
|
||||
`resolverPolicy`. Restrictions are opt-in.
|
||||
|
||||
Canonical resolver policies are:
|
||||
|
||||
- `anyone`: any authenticated actor in the interaction's company who can read the
|
||||
issue and use the normal resolution route. For agents this includes the creator
|
||||
agent and the creating/source run.
|
||||
- `not_creator`: the explicit independent-review policy. It excludes the creator
|
||||
agent and creating/source run while otherwise using the ordinary agent resolver
|
||||
path.
|
||||
- `human_only`: only an authorized human/board actor may resolve the interaction.
|
||||
|
||||
`board_or_agents` and `board_only` are deprecated migration and API-input aliases.
|
||||
For new writes they normalize to `anyone` and `human_only`, respectively. API reads
|
||||
return canonical requested/effective policies, compatibility aliases, and immutable
|
||||
provenance. The persisted provenance is `explicit`, `inherited`, or
|
||||
`legacy_inherited_restriction`; the effective-policy source is `requested`,
|
||||
`company_cap`, or `governed_action`.
|
||||
|
||||
Historical rows predate explicit-vs-inherited provenance. Migration must never
|
||||
silently widen an ambiguous pending card: legacy `board_or_agents` rows retain the
|
||||
old creator-excluding behavior as canonical `not_creator`, legacy `board_only` rows
|
||||
become `human_only`, and both are marked `legacy_inherited_restriction`. Resolved
|
||||
outcomes and resolver attribution are immutable.
|
||||
|
||||
An explicit named addressee and a company-configured cap may narrow the effective
|
||||
audience. A cap never widens the requested audience. Tool-action confirmations and
|
||||
other hard-governed action cards remain `human_only` (or move to the formal approval
|
||||
system) regardless of a requested open audience.
|
||||
|
||||
Surfaces that offer a resolution must state the effective audience before the
|
||||
operator acts, from server metadata rather than a client-side policy inference.
|
||||
Issue-thread cards read it from the interaction snapshot; attention rows read it
|
||||
from the feed item's `resolverAudience` (canonical requested/effective policy,
|
||||
effective-policy source, provenance, and the addressee/creator identities the
|
||||
evaluator compares against), because a collapsed row carries decision verbs
|
||||
before the interaction itself is fetched. A failed resolution keeps the server's
|
||||
denial reason in visible, assertively announced feedback and names who may
|
||||
respond; an audience denial is permanent, so it must not degrade to a retry
|
||||
prompt. Neither surface may enable or disable a control on its own authority.
|
||||
|
||||
Every resolution remains company-scoped, run-attributed for agent actors,
|
||||
low-trust/task-bridge contained, target-current, and exact-once. Target staleness,
|
||||
supersession, continuation idempotency, and activity attribution remain mandatory.
|
||||
An open audience is not an uncapped one: when an agent run resolves an interaction
|
||||
on an issue other than its own source issue, the resolution is a cross-issue
|
||||
mutation and consumes the per-run cross-issue influence budget in §9.3, charged
|
||||
after audience authorization and before the interaction mutation, child tasks,
|
||||
continuation, tool action, or wake. Same-issue resolutions and board/user
|
||||
resolutions are outside that counter.
|
||||
Accepting or answering an interaction records a response only: suggested-task
|
||||
creation, provider/tool calls, deployment, spend, hiring, secrets, execution-policy
|
||||
decisions, and every other downstream effect must re-run its own authorization and
|
||||
approval checks. Mislabeling a governed action as an open interaction grants no
|
||||
downstream capability.
|
||||
|
||||
## 9.9 Task Watchdog Authority Contract
|
||||
|
||||
A task watchdog is a scoped execution capacity for a configured watchdog agent on one watched issue subtree. It is not a separate principal, does not inherit board auth, and does not expand the selected agent's company boundary. The server must enforce the watchdog contract from persisted watchdog configuration and run context; custom instructions and prompt text can narrow the mandate but cannot expand it.
|
||||
@@ -675,7 +740,8 @@ Within the watched subtree, a watchdog run may perform only mutations that resto
|
||||
- reopen `done` or `cancelled` included issues only with explicit resume metadata and an audit comment when evidence shows the stopped disposition is wrong or incomplete
|
||||
- add, replace, or clear blockers on included issues when the blocker target is in the same company and the change makes the waiting path more accurate
|
||||
- set or refresh a one-shot monitor on an included issue when the current assignee owns the future check
|
||||
- accept or reject eligible task-level plan confirmations as defined below
|
||||
- resolve issue-thread interactions through the ordinary resolver-audience path
|
||||
when the watchdog agent otherwise has issue access and the effective policy allows it
|
||||
- update the reusable watchdog issue itself to `done`, `in_review`, or `blocked` with the evidence for the watchdog decision
|
||||
|
||||
Every watchdog-triggered mutation must write activity with the watchdog id, source issue id, watchdog issue id when present, run id, and stop fingerprint. Mutations still use the normal status-transition, blocker, assignment, budget, and company-boundary guards.
|
||||
@@ -713,19 +779,18 @@ When the safe next action needs one of these disallowed mutations, the watchdog
|
||||
|
||||
### Interaction resolution
|
||||
|
||||
The initial V1 watchdog resolver may resolve exactly one interaction family: `request_confirmation` interactions that are eligible task-level plan confirmations. The watchdog may accept a coherent eligible plan or reject/request changes with a reason. It may not resolve `request_checkbox_confirmation`, `ask_user_questions`, `suggest_tasks`, linked approvals, board approvals, or ad hoc document comments.
|
||||
A task-watchdog run has no special resolver audience, plan-purpose marker, or
|
||||
interaction-kind allowlist. The task-watchdog context neither widens nor
|
||||
categorically removes the selected agent's ordinary interaction authority. The
|
||||
same evaluator used for every agent applies `anyone`, `not_creator`, `human_only`,
|
||||
named-addressee, company-cap, company-boundary, run-attribution, low-trust,
|
||||
task-bridge, target-staleness, and exact-once checks.
|
||||
|
||||
A plan confirmation is eligible only when all of these are true:
|
||||
|
||||
- the interaction is pending and belongs to an issue inside the watched subtree, excluding the reusable watchdog issue and its descendants
|
||||
- the interaction target is an `issue_document` with key `plan` on that same issue, and the target revision is still current
|
||||
- the interaction has an explicit plan-approval purpose marker; title text, body prose, or idempotency key shape alone is not enough
|
||||
- accepting the plan authorizes decomposition or task-level continuation inside the watched subtree only
|
||||
- the plan does not request hiring, budget/spend approval, secret access, production deployment, security-sensitive policy changes, legal/compliance decisions, destructive data changes, cross-company work, or any other board-only governed action
|
||||
- no newer board/user comment, document revision, superseding interaction, custom instruction, or issue policy reserves the decision for a human, CTO, Security, or the board
|
||||
- the plan names concrete child/follow-up work, owners or assignee selection criteria, dependencies/blockers, and acceptance criteria clearly enough that decomposition can proceed without further judgment
|
||||
|
||||
If any condition fails, the watchdog must not accept the interaction. It should reject with a reason when the plan is clearly invalid, or leave/escalate the decision when the right owner is a board user, CTO, Security, or another typed approver.
|
||||
Resolving an interaction does not authorize its downstream effect. In particular,
|
||||
an accepted plan still passes normal decomposition/idempotency checks, and a
|
||||
governed action still requires its own typed reviewer, permission, or formal
|
||||
approval. A watchdog may not use an open coordination response to bypass any item
|
||||
in the disallowed-mutations list above.
|
||||
|
||||
### Downstream acceptance criteria
|
||||
|
||||
@@ -734,8 +799,8 @@ Implementation, security, UI, and QA work for task watchdogs must prove these co
|
||||
- server tests deny cross-company watched issues, watchdog agents, watchdog issues, blockers, interactions, and assignment targets
|
||||
- server tests deny paused, terminated, pending-approval, budget-blocked, or otherwise uninvokable watchdog agents
|
||||
- watchdog-scoped mutations can touch only the watched subtree and the reusable watchdog issue, with activity records for each mutation
|
||||
- interaction tests prove only eligible `request_confirmation` plan confirmations are accepted or rejected, and all other interaction kinds remain unavailable to watchdogs
|
||||
- plan-confirmation tests cover stale document revisions, missing purpose markers, outside-subtree targets, governed actions, newer user comments, and explicit human/CTO/Security reservations
|
||||
- interaction tests prove watchdog runs use the same resolver policy as ordinary agents, without a watchdog-only kind or purpose-marker exception
|
||||
- interaction tests cover `anyone`, `not_creator`, `human_only`, named addressees, company caps, stale targets, governed actions, newer user comments, low-trust/task-bridge containment, and cross-company denial
|
||||
- scheduler tests prove live runs, queued wakes, and scheduled retries suppress watchdog wakeups, while terminal, cancelled, blocked, and review leaves are still verified when the subtree has no live path
|
||||
- tests prove `task_watchdog` origin issues and descendants are excluded from scans so watchdogs do not trigger themselves
|
||||
- recovery-batch tests prove batches are capped at 3 allowed mutations, applied all-or-nothing, and aborted with recorded evidence when the observed stop fingerprint went stale mid-batch
|
||||
@@ -745,7 +810,7 @@ Implementation, security, UI, and QA work for task watchdogs must prove these co
|
||||
- prompt/context tests prove custom instructions are appended after non-overridable safety constraints and cannot expand authority
|
||||
- QA validates a full create/edit/remove/run/reuse flow with screenshots for UI changes
|
||||
|
||||
No unresolved policy decision blocks implementation once CTO and Security accept this contract. Deliberately deferred and disallowed for the first implementation: resolving interaction kinds beyond eligible plan confirmations, letting watchdogs cancel active runs, approving board/governance actions, mutating outside the watched subtree, or allowing watchdog agents to modify their own watchdog configuration. Any expansion requires a new product/security review.
|
||||
No unresolved policy decision blocks implementation once CTO and Security accept this contract. Deliberately deferred and disallowed for the first implementation: letting watchdogs cancel active runs, approving board/governance actions, mutating outside the watched subtree, or allowing watchdog agents to modify their own watchdog configuration. Any expansion of those capabilities requires a new product/security review.
|
||||
|
||||
## 9.10 Company Skill Policy Contract
|
||||
|
||||
@@ -870,7 +935,7 @@ Core authorization follows these rules:
|
||||
- Board users may archive or unarchive inbox entries for users in the company.
|
||||
- An agent may manage the responsible user's inbox without an explicit grant when the authenticated run resolves that user and the user's inbox-agent policy permits the agent. This is the default-open path.
|
||||
- A user may set inbox-agent policy to `disabled` or `allowlist`. Policy restrictions override the default-open path, and low-trust agents are denied.
|
||||
- An agent targeting any user other than its resolved responsible user requires an explicit `inbox:manage` grant. Grants may be unscoped or constrained by `scope.userIds`.
|
||||
- An agent targeting any user other than its resolved responsible user requires either a materialized target-user policy that permits that agent (`open` or matching `allowlist`) or an explicit `inbox:manage` grant. The implicit default-open policy for a missing row remains responsible-user-only, so it never becomes a blanket cross-user grant. Grants may be unscoped or constrained by `scope.userIds` and act as administrative overrides, including over a disabled target-user policy.
|
||||
- Archive and unarchive operations are company-scoped, reversible, and activity logged with actor, agent, run, target user, target-resolution source, and policy mode.
|
||||
- New qualifying issue activity may invalidate an archive so the item resurfaces; archival is not a substitute for resolving or closing work.
|
||||
- Viewing an issue may update its per-user read receipt, but read receipts alone do not enroll the issue in Mine. Mine participation begins with a user-authored comment, issue creation/assignment, or another audited user mutation; explicit product actions such as manually running a routine may record an audited inbox touch.
|
||||
@@ -1032,7 +1097,8 @@ Dashboard payload must include:
|
||||
|
||||
The current app also exposes V1-supporting surfaces for:
|
||||
|
||||
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`)
|
||||
- company-scoped summary slots for projects, the workspaces overview, project workspaces, and individual execution workspaces; execution-workspace slots are keyed by execution workspace id so a new workspace never inherits another workspace's summary
|
||||
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) with the open-default resolver contract in §9.8.1
|
||||
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
|
||||
- company search through `GET /companies/:companyId/search` plus agent-oriented bulk extraction through
|
||||
`GET /companies/:companyId/search/extract`; extraction accepts a server-escaped literal `contains`, optional
|
||||
|
||||
@@ -101,17 +101,17 @@ On wake, the watchdog agent reads a fixed default mandate plus your custom instr
|
||||
|
||||
- Treat every stopped leaf as a **claim** that must be verified against comments, documents, work products, screenshots, tests, blockers, and review state. Do not accept "I could not" or "waiting for approval" as automatically valid.
|
||||
- Leave genuinely-complete leaves alone, with a short note on what was checked.
|
||||
- If a leaf is not genuinely complete, restore a live path: reopen the issue, reassign, comment actionable instructions, create a follow-up child issue inside the watched subtree, or accept an eligible task-level plan confirmation.
|
||||
- If a leaf is not genuinely complete, restore a live path: reopen the issue, reassign, comment actionable instructions, create a follow-up child issue inside the watched subtree, or resolve an interaction that the ordinary agent audience permits.
|
||||
- If the blocker is real, leave a valid waiting disposition that names the unblock owner and the next action.
|
||||
|
||||
The mandate also enforces safety constraints that custom instructions **cannot override**:
|
||||
|
||||
- Stay inside the watched subtree. No cross-company mutations, no mutations outside the watched issue and its non-watchdog descendants.
|
||||
- No impersonating board-only approvals, accepting spend or hiring decisions, accepting security-sensitive interactions, or bypassing execution-policy stages that require a typed reviewer or approver.
|
||||
- No impersonating formal approvals, accepting spend or hiring decisions, using an interaction response as downstream authority, or bypassing execution-policy stages that require a typed reviewer or approver.
|
||||
- No creating another watchdog for the watched subtree. No waking itself. Exactly one reusable review task per watched issue.
|
||||
- Custom instructions can narrow focus or veto specific shortcuts. They cannot grant authority the server does not already give the watchdog.
|
||||
|
||||
The formal authority contract (the full list of allowed and disallowed mutations, and the eligibility test for accepting plan confirmations) is in [`doc/SPEC-implementation.md`](SPEC-implementation.md) §9.9.
|
||||
The formal authority contract (the full list of allowed and disallowed mutations, plus the ordinary interaction resolver contract) is in [`doc/SPEC-implementation.md`](SPEC-implementation.md) §§9.8.1–9.9.
|
||||
|
||||
### Writing custom instructions
|
||||
|
||||
@@ -119,11 +119,11 @@ Custom instructions are most useful when they tell the watchdog what evidence to
|
||||
|
||||
> Before accepting any leaf as done, check that there is a corresponding green CI run linked in the comments. If there isn't, reopen the leaf and ask for one.
|
||||
|
||||
> Do not accept a `request_confirmation` plan that proposes more than five subtasks without first asking me to review. Leave the issue in review and ping me.
|
||||
> When an interaction is `not_creator` or `human_only`, preserve that restriction. Do not treat the watchdog role as an override.
|
||||
|
||||
> If a leaf is blocked on the marketing team, accept the wait but make sure the unblock owner is named in the blocker reason.
|
||||
|
||||
What custom instructions cannot do: grant authority outside the watched subtree, approve board-level decisions, expand the interaction kinds the watchdog can resolve, or override safety constraints. The server enforces this regardless of what the instructions say.
|
||||
What custom instructions cannot do: grant authority outside the watched subtree, approve formal governance decisions, widen an interaction's resolver audience, or override safety constraints. The server enforces this regardless of what the instructions say.
|
||||
|
||||
---
|
||||
|
||||
@@ -133,7 +133,7 @@ Every watchdog-originated mutation is gated by a server-side scope check derived
|
||||
|
||||
- mutations on issues outside the watched subtree (parent-chain walk, depth-limited)
|
||||
- mutations on issues whose company id does not match the watchdog's company
|
||||
- attempts to resolve interactions other than eligible task-level `request_confirmation` plan confirmations (see SPEC §9.9 for eligibility)
|
||||
- interaction resolution outside the ordinary effective audience, named addressee, company/issue scope, run-attribution, low-trust/task-bridge, target-staleness, or exact-once checks
|
||||
- changes to the watchdog configuration itself (a watchdog cannot edit its own row or create another watchdog)
|
||||
- direct edits to active-run output or execution-policy decisions that require a typed participant
|
||||
|
||||
@@ -165,7 +165,7 @@ It is **not** the right tool for:
|
||||
|
||||
- monitoring a single running process for silence — that is the silent active-run watchdog, automatic, no configuration
|
||||
- liveness recovery on stalled agent-owned issues without an explicit recovery surface — that is automatic too
|
||||
- board-level approvals or anything security-sensitive — the watchdog cannot resolve those
|
||||
- formal approvals or security-sensitive downstream actions — an interaction response cannot authorize those
|
||||
- replacing a human reviewer on a typed execution-policy stage — the watchdog cannot bypass typed participants
|
||||
|
||||
If what you actually want is "wake me when this is done," use a routine or an issue-thread interaction with `continuationPolicy: wake_assignee`, not a watchdog.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# ACP run lifecycle
|
||||
|
||||
This document describes how one ACP agent run starts, runs its turn, and releases
|
||||
its resources. It covers the run coordinator, the six run resources, the
|
||||
settlement order, the server-owned staging lease, and the known limitations.
|
||||
|
||||
## The run coordinator
|
||||
|
||||
One `run()` coordinator owns one attempt. It sequences four steps in a fixed
|
||||
order:
|
||||
|
||||
1. **Startup.** Bring up the runtime and establish the session.
|
||||
2. **Turn.** Run the agent turn against the ready runtime.
|
||||
3. **Settlement.** Release every resource the run acquired.
|
||||
4. **Result reproduction.** Return the external result to the caller.
|
||||
|
||||
The coordinator holds the routing table as data. It reads the startup outcome and
|
||||
routes on it:
|
||||
|
||||
- A build failure or a partial-bridge failure throws from startup. The coordinator
|
||||
replays the startup-rollback entries and rethrows the original error.
|
||||
- A runtime-create, handshake, missing-handle, or configuration failure produces a
|
||||
settle result. The coordinator settles the resources, then reproduces the error
|
||||
result.
|
||||
- Every other startup outcome produces a ready result. The coordinator runs the
|
||||
turn, settles the resources, then reproduces the turn result.
|
||||
|
||||
The coordinator has no catch around the turn: the turn never rejects and returns a
|
||||
typed completion. The coordinator reproduces the external result AFTER settlement,
|
||||
so a caller never observes a result before the run releases its resources.
|
||||
|
||||
## The six run resources
|
||||
|
||||
A run resource ledger holds the resources between acquisition and settlement. The
|
||||
ledger is the single owner. The closed set of run resources is:
|
||||
|
||||
- `acp_runtime` — the composite of the runtime, the session handle, and the child
|
||||
process. `runtime.close()` is the one release boundary for all three.
|
||||
- `staged_runtime` — the workspace and the runtime staged into the sandbox, plus
|
||||
the one-time host-side cleanup of the staged temporary files.
|
||||
- `control_bridge` — the control-plane callback bridge.
|
||||
- `agent_bridge` — the agent process-session bridge.
|
||||
- `managed_home` — the per-run managed-home copy-back hook.
|
||||
- `staging_lease` — the per-session staging lease.
|
||||
|
||||
The host lane acquires only `acp_runtime`. The sandbox lane also acquires the
|
||||
staging and transport resources. Each resource enters the ledger the moment it
|
||||
exists, so the settlement always closes it, even on an early failure.
|
||||
|
||||
## The settlement order
|
||||
|
||||
The settlement sequence is the one live cleanup owner for every settled path. It
|
||||
claims the ledger once, makes the pure reuse decision, then runs the ordered
|
||||
steps:
|
||||
|
||||
1. `end_session` — close every runtime the reuse decision did not transfer, and
|
||||
drop the warm entry when it closes.
|
||||
2. `settle_reuse` — perform the decision. A save transfers the reuse candidate to
|
||||
the site store; every other case discards it.
|
||||
3. `stop_transport` — stop both bridges in one settled batch.
|
||||
4. `sync_back` — run the site sync-back (the managed-home copy-back).
|
||||
5. `release_staging_lease` — release the staging lease last (see below).
|
||||
|
||||
An error policy governs every step: a step records its error, and the later steps
|
||||
still run. Every step is a no-op on an empty resource slot.
|
||||
|
||||
The reuse decision is pure and runs before any close. A save is eligible only when
|
||||
a candidate exists, the settlement cause permits a save, and no run-scoped
|
||||
credential that the candidate would carry into the store remains valid.
|
||||
|
||||
## The server-owned staging lease outer context
|
||||
|
||||
The staging lease is a per-session lease. Only one run of a session may stage into
|
||||
the same remote workspace at a time. A second run of the same session waits on the
|
||||
lease until the first run releases it.
|
||||
|
||||
The lease releases as the run's final act, after the coordinator settles every
|
||||
other resource AND reproduces the result. This ordering keeps a same-session
|
||||
second run blocked on the lease until the first run fully returns, so the second
|
||||
run never re-stages into a workspace the first run still uses. The release runs in
|
||||
a `finally`, so an earlier teardown fault never strands the lease.
|
||||
|
||||
## Per-phase telemetry
|
||||
|
||||
The run emits one telemetry event per named lifecycle phase. Each event carries
|
||||
only the phase name, the wall-time duration, and the outcome (`ok` or `failed`).
|
||||
The phase name is one member of a closed allowlist. An event never carries a
|
||||
command, an argument, a path, an environment value, or a raw identifier. A
|
||||
telemetry failure never fails the run.
|
||||
|
||||
## Known limitations and deferred work
|
||||
|
||||
- **Host-lane runtime reuse is disabled.** A run-minted API key is a stateless
|
||||
token that the control plane never revokes on run end. A warm host runtime would
|
||||
carry a still-valid credential into the next run. The host lane therefore closes
|
||||
and relaunches on every run, rather than keeping the runtime warm, until a
|
||||
run-scoped credential rebind protocol exists.
|
||||
- **The sandbox staged-files reuse stays enabled.** Its reuse payload carries no
|
||||
credential, so a compatible resume reuses the already-staged runtime.
|
||||
- **The per-phase telemetry is observability-only.** It records the duration and
|
||||
the outcome of each phase; it does not change run control flow.
|
||||
@@ -199,10 +199,31 @@ most; the broker resolves endpoints at connect time:
|
||||
dynamic registration — `registration_endpoint`.
|
||||
|
||||
The broker implements this in `discoverOAuthEndpoints`
|
||||
(`server/src/services/tool-access.ts`); connections with transport
|
||||
`mcp_remote` and `auth: "oauth"` prefer discovered endpoints over manifest
|
||||
hints. Keep manifest hints current anyway so reviewers can read the expected
|
||||
endpoints without running discovery.
|
||||
(`server/src/services/tool-access.ts`), but discovery is **not**
|
||||
unconditional. `oauthEndpointsForConnection` resolves endpoints in this
|
||||
order:
|
||||
|
||||
1. If the manifest's method `defaults` ship a **complete** pair
|
||||
(`authorizationEndpoint` **and** `tokenEndpoint`), those are used
|
||||
unconditionally. `discoverOAuthEndpoints` never runs in this case, so
|
||||
endpoints stored on the connection's own OAuth config and 401 challenge
|
||||
hints are **not consulted at all**.
|
||||
2. Otherwise, for `mcp_remote` connections, the broker calls
|
||||
`discoverOAuthEndpoints`, which first checks endpoints already stored on
|
||||
the connection's own OAuth config (falling back field-by-field to the 401
|
||||
challenge hints); a complete stored/hinted pair is used as-is — no
|
||||
`.well-known` fetch.
|
||||
3. Only when neither of the above yields a complete pair does the broker run
|
||||
the RFC 9728 → RFC 8414 discovery chain above.
|
||||
|
||||
Consequence: complete manifest endpoint hints are **authoritative, not
|
||||
hints** — they override even endpoints that an earlier discovery persisted
|
||||
on the connection, and if they go stale the broker keeps using them. For
|
||||
discovery-capable vendors, ship only `serverUrl` in `defaults` (as
|
||||
`notion.json` does) so the broker discovers fresh endpoints at connect
|
||||
time; add explicit `authorizationEndpoint`/`tokenEndpoint` only for vendors
|
||||
that do not publish RFC 9728/8414 metadata, and then own keeping them
|
||||
current.
|
||||
|
||||
### Dynamic client registration (RFC 7591)
|
||||
|
||||
@@ -497,7 +518,10 @@ alone.
|
||||
- Transport: `mcp_remote`
|
||||
- Endpoint: `https://mcp.notion.com/mcp` (Streamable HTTP; `/sse` fallback exists)
|
||||
- Auth mode: OAuth, endpoints resolved by discovery (RFC 9728 → RFC 8414),
|
||||
public client via RFC 7591 DCR with PKCE S256 mandatory.
|
||||
public client via RFC 7591 DCR with PKCE S256 mandatory. Discovery runs
|
||||
because `notion.json` deliberately ships only `serverUrl` — no
|
||||
`authorizationEndpoint`/`tokenEndpoint` hints, which would otherwise take
|
||||
precedence and be used verbatim (see "MCP-Direct Connections" above).
|
||||
- Ownership modes: `dcr` (default, zero setup) and `customer`
|
||||
(env-registered classic integration via
|
||||
`PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID/_SECRET`, which always wins when set).
|
||||
|
||||
@@ -44,7 +44,7 @@ Hold every session to these five, regardless of size:
|
||||
## Reviewing like a designer
|
||||
|
||||
- **Contact sheet**: the diff images under `tests/storybook-visual/test-results/` are your primary review surface; ask the agent to assemble them into a browsable before/after page (or use `npx playwright show-report` from `tests/storybook-visual/`).
|
||||
- **Live test drive**: for big changes, ask for a running instance from the worktree — `pnpm paperclipai worktree init` once, then `PORT=3300 pnpm dev:once` gives an isolated Paperclip (own database, own config; your real instance is untouched). Click around; real use surfaces what screenshots can't.
|
||||
- **Live test drive**: for big changes, ask for a running instance from the worktree — `npx paperclipai worktree init` once, then `PORT=3300 pnpm dev:once` gives an isolated Paperclip (own database, own config; your real instance is untouched). Click around; real use surfaces what screenshots can't.
|
||||
- **Side-by-side Storybook**: old on one port, new on another (`pnpm storybook` in each checkout), flip tabs.
|
||||
- Trust your eyes over the agent's summary. If something looks wrong, say so plainly ("the text in the red boxes is illegible") — vague feedback is fine, the screenshots give the agent the precision.
|
||||
|
||||
|
||||
+35
-10
@@ -575,6 +575,28 @@ On startup and on the periodic recovery loop, Paperclip now does five things in
|
||||
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
|
||||
|
||||
### Issue-thread interaction resolution
|
||||
|
||||
Every issue-thread interaction kind inherits resolver policy `anyone` when the
|
||||
creator omits a policy. `anyone` includes the creator agent and creating run.
|
||||
Callers opt into independent review with `not_creator` or human resolution with
|
||||
`human_only`; a named addressee and company cap may narrow the effective audience.
|
||||
The legacy input aliases `board_or_agents` and `board_only` normalize to `anyone`
|
||||
and `human_only` for new writes.
|
||||
|
||||
Resolver policy is snapshotted with explicit/inherited provenance and an effective
|
||||
source (`requested`, `company_cap`, or `governed_action`). Existing ambiguous
|
||||
legacy rows are marked `legacy_inherited_restriction` and retain their old
|
||||
restriction: `board_or_agents` becomes `not_creator`, not `anyone`, while
|
||||
`board_only` becomes `human_only`. Pending cards are never silently widened.
|
||||
|
||||
Resolution is exact-once and requires issue access in the same company. Agent
|
||||
resolution also requires valid run attribution and remains subject to low-trust
|
||||
and task-bridge containment. Target freshness and supersession are checked before
|
||||
the outcome commits. Resolution records an answer; every continuation, task
|
||||
creation, tool/provider call, execution-policy transition, spend, deployment, or
|
||||
other effect independently re-runs its own authorization and approval gates.
|
||||
|
||||
## 11. Task Watchdog for Issue Trees
|
||||
|
||||
A task watchdog watches a configured issue subtree after that subtree has stopped moving. It is a product-level verification and recovery mechanism for selected work, not a process monitor.
|
||||
@@ -623,13 +645,13 @@ When the source issue is non-terminal and has no other live path, the product sh
|
||||
|
||||
### Watchdog authority during execution
|
||||
|
||||
The watchdog agent acts in a scoped capacity, not as the original deliverable worker and not as the board. The server must enforce the authority contract in `doc/SPEC-implementation.md` from persisted watchdog context. Prompt text and custom instructions may guide the watchdog's judgment, but they cannot grant authority outside the watched subtree or beyond the allowed mutation and interaction list.
|
||||
The watchdog agent acts in a scoped capacity, not as the original deliverable worker and not as the board. The server must enforce the authority contract in `doc/SPEC-implementation.md` from persisted watchdog context. Prompt text and custom instructions may guide the watchdog's judgment, but they cannot grant authority outside the watched subtree or widen an interaction's ordinary effective audience.
|
||||
|
||||
Watchdogs must not create visible probe issues, comments, or throwaway tasks to discover capability boundaries. They should rely on the wake capability metadata and explicit API denials, then record any denied operation as evidence in the reusable watchdog issue.
|
||||
|
||||
The watchdog should verify stopped leaves against comments, documents, work products, tests, screenshots, blockers, review state, and run context. It should not accept "I could not" or "waiting for approval" as sufficient by itself.
|
||||
|
||||
When work should continue, the watchdog restores a live path inside the watched subtree: reopen or reassign stuck work, create follow-up issues, repair blockers, set a monitor, or resolve an eligible plan confirmation. When the stopped state is legitimate, the watchdog records why and leaves the subtree with a valid terminal, waiting, blocked, review, or explicit recovery path.
|
||||
When work should continue, the watchdog restores a live path inside the watched subtree: reopen or reassign stuck work, create follow-up issues, repair blockers, set a monitor, or resolve an interaction that its ordinary agent audience permits. When the stopped state is legitimate, the watchdog records why and leaves the subtree with a valid terminal, waiting, blocked, review, or explicit recovery path.
|
||||
|
||||
### Atomic recovery batch
|
||||
|
||||
@@ -637,16 +659,19 @@ Restoration is often more than one write — reopen the dead-end leaf **and** ex
|
||||
|
||||
This preserves the stale-guard's purpose — never keep mutating a subtree that just went live under the watchdog's feet — while removing the failure mode where spending the only permitted write on an informational comment forfeits the state-restoring mutation the recovery actually needed.
|
||||
|
||||
### Eligible interaction decisions
|
||||
### Interaction decisions
|
||||
|
||||
A task watchdog may resolve only eligible `request_confirmation` plan confirmations. Eligibility is defined in `doc/SPEC-implementation.md` and must be checked by the server at decision time. The critical constraints are:
|
||||
A task watchdog is an ordinary agent for interaction resolution. Its watchdog
|
||||
context provides no special audience, plan-purpose marker, or kind allowlist, and
|
||||
it is not a categorical denial. The normal evaluator checks the effective policy,
|
||||
named addressee, company and issue scope, run attribution, low-trust/task-bridge
|
||||
containment, target freshness, and exact-once state.
|
||||
|
||||
- the interaction is pending, targeted at the current `plan` document revision for an included subtree issue, and explicitly marked as a plan-approval confirmation
|
||||
- accepting it authorizes only decomposition or task-level continuation inside the watched subtree
|
||||
- the plan is not asking for board-only governance, spend, hiring, security, deployment, secret, destructive data, legal/compliance, cross-company, or other sensitive approval
|
||||
- no newer durable source activity or policy reserves the decision for a human, CTO, Security, or the board
|
||||
|
||||
The watchdog cannot resolve `request_checkbox_confirmation`, `ask_user_questions`, `suggest_tasks`, linked approvals, execution-policy decisions unless it is the typed participant outside watchdog capacity, or document comments written as freeform approval.
|
||||
This does not give a watchdog downstream authority. Linked/formal approvals remain
|
||||
separate, execution-policy decisions still require the typed participant, and an
|
||||
accepted interaction cannot authorize spend, hiring, secrets, deployment,
|
||||
destructive data changes, cross-company work, or any mutation the watchdog scope
|
||||
otherwise forbids.
|
||||
|
||||
### Completion and fingerprint updates
|
||||
|
||||
|
||||
@@ -245,10 +245,10 @@ This on-disk model is the reason the current implementation expects a persistent
|
||||
Paperclip should add CLI commands:
|
||||
|
||||
- `pnpm paperclipai plugin list`
|
||||
- `pnpm paperclipai plugin install <package[@version]>`
|
||||
- `pnpm paperclipai plugin uninstall <plugin-id>`
|
||||
- `pnpm paperclipai plugin upgrade <plugin-id> [version]`
|
||||
- `pnpm paperclipai plugin doctor <plugin-id>`
|
||||
- `npx paperclipai plugin install <package[@version]>`
|
||||
- `npx paperclipai plugin uninstall <plugin-id>`
|
||||
- `npx paperclipai plugin upgrade <plugin-id> [version]`
|
||||
- `npx paperclipai plugin doctor <plugin-id>`
|
||||
|
||||
These commands are instance-level operations.
|
||||
|
||||
@@ -395,6 +395,11 @@ Rules:
|
||||
- `projects` → `projects.managed`
|
||||
- `routines` → `routines.managed`
|
||||
- `skills` → `skills.managed`
|
||||
- an `environmentDrivers` entry with `kind: "sandbox_provider"` declares sandbox
|
||||
capabilities through `sandboxCapabilities`. See the
|
||||
[sandbox provider capability contract](./SANDBOX_PROVIDER_CAPABILITIES.md) for
|
||||
the supported keys, the worker-method prerequisites, and the narrowing and
|
||||
failure rules.
|
||||
|
||||
## 11. Agent Tools
|
||||
|
||||
@@ -1512,7 +1517,7 @@ When a plugin is uninstalled, the host must handle plugin-owned data explicitly.
|
||||
3. Plugin-owned data (`plugin_state`, `plugin_entities`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries`, `plugin_config`) is retained for a configurable grace period (default: 30 days).
|
||||
4. During the grace period, the operator can reinstall the same plugin and recover its state.
|
||||
5. After the grace period, the host purges all plugin-owned data for the uninstalled plugin.
|
||||
6. The operator may force-purge immediately via CLI: `pnpm paperclipai plugin purge <plugin-id>`.
|
||||
6. The operator may force-purge immediately via CLI: `npx paperclipai plugin purge <plugin-id>`.
|
||||
|
||||
### 25.2 Upgrade Data Considerations
|
||||
|
||||
@@ -1676,7 +1681,7 @@ expect(data.syncedCount).toBeGreaterThan(0);
|
||||
|
||||
For developing a plugin against a running Paperclip instance:
|
||||
|
||||
- The operator installs the plugin from a local path: `pnpm paperclipai plugin install ./path/to/plugin`
|
||||
- The operator installs the plugin from a local path: `npx paperclipai plugin install ./path/to/plugin`
|
||||
- The host watches the plugin directory for changes and restarts the worker on rebuild.
|
||||
- `devUiUrl` in plugin config can point to a local Vite dev server for UI hot-reload.
|
||||
- The plugin settings page shows real-time logs from the worker for debugging.
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# Sandbox provider capability contract
|
||||
|
||||
A sandbox provider plugin declares an environment driver with
|
||||
`kind: "sandbox_provider"`. Each driver can declare a set of optional sandbox
|
||||
capabilities. This document is the contract for a third-party provider author.
|
||||
It states what to declare, which worker methods each capability needs, what an
|
||||
omitted key means, and when the host narrows or denies a capability.
|
||||
|
||||
Read [Sandbox file-sync lifecycle hooks](./SANDBOX_FILE_SYNC_HOOKS.md) for the
|
||||
native file-transfer hooks. Read the driver declaration shape in
|
||||
[the plugin specification](./PLUGIN_SPEC.md).
|
||||
|
||||
## How the host resolves an effective capability
|
||||
|
||||
The host never trusts a declaration alone. For every run it resolves each
|
||||
capability as the intersection of three inputs:
|
||||
|
||||
```
|
||||
effective = verified ∩ declared ∩ narrowing
|
||||
```
|
||||
|
||||
- **verified** — the methods the live worker advertised in
|
||||
`InitializeResult.supportedMethods`. The host maps each capability to the
|
||||
worker methods it needs (see the table below). A capability is verified only
|
||||
when the worker advertises every required method. An empty or missing method
|
||||
list verifies nothing, so every capability resolves `false`.
|
||||
- **declared** — the values in the driver declaration. A declared key is
|
||||
optional (see the next section).
|
||||
- **narrowing** — a per-run restriction from the lease policy or the resolved
|
||||
provider config. A narrowing can only remove a capability, never add one.
|
||||
|
||||
A capability is effective only when all three allow it. A declaration therefore
|
||||
never grants a capability that the worker did not verify.
|
||||
|
||||
## The declaration is optional and partial
|
||||
|
||||
Declare capabilities through the nested `sandboxCapabilities` object on the
|
||||
driver declaration:
|
||||
|
||||
```ts
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "my-provider",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "My Provider",
|
||||
configSchema: { type: "object", properties: {} },
|
||||
sandboxCapabilities: {
|
||||
reusableLeases: true,
|
||||
nativeSyncIn: true,
|
||||
nativeSyncOut: true,
|
||||
persistentProcessSessions: false,
|
||||
independentControlCommands: false,
|
||||
incrementalSessionOutput: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
Every key is optional. For a valid, identified provider each key has one of three
|
||||
states:
|
||||
|
||||
- **Omitted** — the host defers to verified worker discovery. The capability is
|
||||
effective when the worker advertises the required methods and no narrowing
|
||||
removes it. Omission is the correct default for a provider that follows the
|
||||
standard method contract. `reusableLeases` and `incrementalSessionOutput` are
|
||||
the two exceptions: an omitted key never grants either capability. Both are
|
||||
opt-in (see the next two sections).
|
||||
- **`false`** — the host narrows the capability to off. The capability is never
|
||||
effective, even when the worker advertises the required methods.
|
||||
- **`true`** — the host still requires the verified prerequisites. A `true`
|
||||
value never grants a capability without them. It documents intent and lets the
|
||||
host present the capability, but the worker must still advertise the required
|
||||
methods.
|
||||
|
||||
## Reusable leases need an explicit opt-in
|
||||
|
||||
Reusable leases are the exception to the omission rule above. The host grants
|
||||
reusable-lease acquisition only when the declaration sets `reusableLeases` to
|
||||
`true`. The provider opts in through one of two fields:
|
||||
|
||||
- the nested `sandboxCapabilities.reusableLeases: true`, or
|
||||
- the legacy `supportsReusableLeases: true`.
|
||||
|
||||
An omitted key leaves `reusableLeases` unset. An unset key does not make the
|
||||
provider eligible for reusable-lease acquisition, and it does not advertise
|
||||
provider-level reusable support. The host then always creates an ephemeral lease.
|
||||
|
||||
The opt-in never removes the other prerequisites. The worker must still verify
|
||||
all three lifecycle methods, `environmentResumeLease`, `environmentReleaseLease`,
|
||||
and `environmentDestroyLease`, and per-run narrowing still applies.
|
||||
|
||||
The two opt-in fields have a fixed precedence. The host keeps the legacy
|
||||
`supportsReusableLeases` field for backward compatibility, and it folds the field
|
||||
into `sandboxCapabilities.reusableLeases`.
|
||||
|
||||
- When only `supportsReusableLeases` is present, the host reads it as
|
||||
`reusableLeases`.
|
||||
- When both `supportsReusableLeases` and `sandboxCapabilities.reusableLeases` are
|
||||
present, the nested value wins.
|
||||
|
||||
A manifest with legacy `true` and nested `false` therefore resolves to `false`.
|
||||
Prefer the nested `sandboxCapabilities.reusableLeases` in a new manifest.
|
||||
|
||||
## Incremental session output needs an explicit opt-in
|
||||
|
||||
Incremental session output is the second exception to the omission rule. The host
|
||||
selects the session-output streaming path only when the declaration sets
|
||||
`incrementalSessionOutput` to `true`. An omitted key resolves the capability to
|
||||
`false`, so the host keeps the output-file poll path.
|
||||
|
||||
The reason is that this key is a behavioral guarantee, not a worker-method
|
||||
property. A generic one-shot provider can keep persistent process sessions and run
|
||||
independent control commands, yet it never emits incremental stdout and stderr
|
||||
from a live session. The two broad capabilities do not imply incremental output,
|
||||
so the host requires the provider to declare the behavior. A provider that streams
|
||||
incremental session output declares `sandboxCapabilities.incrementalSessionOutput:
|
||||
true`; every other provider omits the key and keeps the poll path.
|
||||
|
||||
The opt-in never removes the prerequisites. The worker must still verify
|
||||
`environmentExecute`, and per-run narrowing still applies. A config-resolution
|
||||
failure narrows the capability to off (see [Failure behavior](#failure-behavior)).
|
||||
|
||||
## The capabilities and their worker-method prerequisites
|
||||
|
||||
| Capability | Required worker methods | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `reusableLeases` | `environmentResumeLease`, `environmentReleaseLease`, **and** `environmentDestroyLease` | The host retains a provider lease and resumes it across runs. |
|
||||
| `nativeSyncIn` | `environmentSyncIn` | The host transfers files into the sandbox through the native inbound hook. |
|
||||
| `nativeSyncOut` | `environmentSyncOut` | The host transfers files out of the sandbox through the native outbound hook. |
|
||||
| `persistentProcessSessions` | `environmentExecute` | The provider keeps a persistent process session open across commands. |
|
||||
| `independentControlCommands` | `environmentExecute` | The provider runs a one-shot control command beside a long-lived command. |
|
||||
| `incrementalSessionOutput` | `environmentExecute` | The provider streams incremental stdout and stderr from a live session. Opt-in: an omitted key resolves `false`. |
|
||||
|
||||
Reusable leases need all three lifecycle methods. The host resumes a lease with
|
||||
`environmentResumeLease`, ends it with `environmentReleaseLease`, and tears down a
|
||||
stale lease with `environmentDestroyLease`. The reuse path destroys a stale lease
|
||||
when a resume fails, so a provider that cannot destroy a lease would strand it. A
|
||||
worker that omits any of the three methods never gets reusable leases, even with a
|
||||
positive declaration. The host then always creates an ephemeral lease.
|
||||
|
||||
The host advertises and consumes the two native sync methods as a pair. Define
|
||||
both or neither. See [Sandbox file-sync lifecycle hooks](./SANDBOX_FILE_SYNC_HOOKS.md).
|
||||
|
||||
## Target and config narrowing
|
||||
|
||||
A narrowing removes a capability that the provider verified and declared but that
|
||||
this run cannot use.
|
||||
|
||||
- **Ephemeral lease policy.** A lease that the host does not retain never reuses.
|
||||
The host narrows `reusableLeases` to off for an ephemeral lease and keeps it on
|
||||
only for a reuse-by-environment lease.
|
||||
- **Kubernetes Job backend.** A Job-backed lease, or a lease the host marked as
|
||||
unable to run native file sync, disables native sync. The host narrows
|
||||
`nativeSyncIn` and `nativeSyncOut` to off and keeps the base64-over-exec
|
||||
fallback.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
The host fails closed on two failure states. It never grants a capability from an
|
||||
unknown state.
|
||||
|
||||
- **Config-resolution failure.** A provider whose config the host cannot resolve
|
||||
is untrusted. The host narrows `persistentProcessSessions` and
|
||||
`incrementalSessionOutput` to off instead of allowing either through an empty
|
||||
config.
|
||||
- **Exact-plugin identity failure.** A retained lease pins the exact plugin that
|
||||
acquired it. When that plugin is absent, or when it no longer declares this
|
||||
provider key with the `sandbox_provider` kind, the host cannot establish the
|
||||
declaration. It resolves every effective capability to `false`, no matter what
|
||||
methods a stale worker still advertises. An omitted `sandboxCapabilities`
|
||||
object on a valid, identified plugin is a different state; the host defers to
|
||||
verified discovery for that case.
|
||||
|
||||
## Concurrency capabilities are not part of the contract
|
||||
|
||||
Earlier drafts listed two concurrency keys, `concurrentSyncAndExec` and
|
||||
`concurrentSyncOperations`. The runtime never exposed a scheduling choice that
|
||||
read either key, so a declaration had no effect. The host removed both keys. The
|
||||
strict capability validator now rejects them as unknown keys. The host can
|
||||
reintroduce a concurrency capability when a runtime path enforces it.
|
||||
@@ -37,4 +37,9 @@ WORKDIR /home/paperclip/workspace
|
||||
EXPOSE 3100
|
||||
USER paperclip
|
||||
|
||||
CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""]
|
||||
# --bind lan is required: `onboard --yes` without an explicit --bind prefers
|
||||
# trusted-local defaults, which write a loopback bind into the instance config
|
||||
# and ignore the deployment env vars. A loopback listener inside the container
|
||||
# is unreachable through Docker's port mapping, so the smoke health check can
|
||||
# never pass without this flag.
|
||||
CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --bind lan --data-dir \"$PAPERCLIP_HOME\""]
|
||||
@@ -102,7 +102,7 @@ therefore shadows any Codex login already present inside the sandbox image.
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
npx paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
||||
@@ -141,7 +141,7 @@ change.
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
npx paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
||||
+9
-5
@@ -179,7 +179,7 @@ POST /api/issues/{issueId}/comments
|
||||
|
||||
## Issue-Thread Interactions
|
||||
|
||||
Interactions are structured cards in the issue thread. Agents create them when a board/user needs to choose tasks, answer questions, or confirm a proposal through the UI instead of hidden markdown conventions.
|
||||
Interactions are structured cards in the issue thread. Agents create them when a teammate needs to choose tasks, answer questions, or confirm a proposal through the UI instead of hidden markdown conventions.
|
||||
|
||||
### List Interactions
|
||||
|
||||
@@ -193,7 +193,7 @@ GET /api/issues/{issueId}/interactions
|
||||
POST /api/issues/{issueId}/interactions
|
||||
{
|
||||
"kind": "request_confirmation",
|
||||
"resolverPolicy": "board_only",
|
||||
"resolverPolicy": "human_only",
|
||||
"idempotencyKey": "confirmation:{issueId}:plan:{revisionId}",
|
||||
"title": "Plan approval",
|
||||
"summary": "Waiting for the board/user to accept or request changes.",
|
||||
@@ -227,9 +227,11 @@ Supported `kind` values:
|
||||
- `request_checkbox_confirmation`: ask for one accept/reject decision over selected option ids
|
||||
- `request_item_verdicts`: collect approve/reject/defer verdicts per item
|
||||
|
||||
`resolverPolicy: "board_only" | "board_or_agents"`. Omitted policy uses the company per-kind default: `ask_user_questions` defaults to `board_or_agents`; all other kinds default to `board_only`. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance`, keyed by kind, with optional `defaultPolicy` and `cap`. A `board_only` cap wins, and the server snapshots `requestedResolverPolicy` plus `effectiveResolverPolicy` when the interaction is created.
|
||||
Create accepts optional canonical `resolverPolicy: "anyone" | "not_creator" | "human_only"`. Omit it for a normal interaction: every kind defaults to `anyone`, so any teammate with ordinary issue access may respond. Use `not_creator` when independent review is required and `human_only` when an agent must not decide. Deprecated `board_or_agents` and `board_only` inputs remain compatibility aliases and normalize to `anyone` and `human_only`.
|
||||
|
||||
`addresseeAgentId` optionally targets a same-company agent. The addressee is woken with `interaction_pending`, and only that agent or a board user may resolve the card; the creator cannot address itself, tool-action confirmations with an addressee return `400`, and all low-trust/watchdog/same-run restrictions remain. Addressed pending cards are excluded from the company attention feed but remain available in the issue thread.
|
||||
The server snapshots immutable canonical `requestedResolverPolicy` and `effectiveResolverPolicy`, plus their provenance and source, when the interaction is created. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance`, keyed by kind, with optional `defaultPolicy` and `cap`; governance may narrow but never widen the requested audience. Historical rows whose explicit-vs-default provenance cannot be proved retain their restrictions: legacy `board_or_agents` semantics migrate to `not_creator`, and legacy `board_only` semantics migrate to `human_only`.
|
||||
|
||||
`addresseeAgentId` optionally targets a same-company agent. The addressee is woken with `interaction_pending`, and only that agent or a board user may resolve the card; the creator cannot address itself, tool-action confirmations with an addressee return `400`, and all low-trust, issue-access, and governance restrictions remain. Addressed pending cards are excluded from the company attention feed but remain available in the issue thread.
|
||||
|
||||
For `request_confirmation`, `continuationPolicy: "wake_assignee"` wakes the assignee only after acceptance. Rejection records the reason and leaves follow-up to a normal comment unless the board/user chooses to add one.
|
||||
|
||||
@@ -243,7 +245,9 @@ POST /api/issues/{issueId}/interactions/{interactionId}/verdicts
|
||||
POST /api/issues/{issueId}/interactions/{interactionId}/withdraw
|
||||
```
|
||||
|
||||
Board users can resolve all interactions. Agent resolution requires the immutable effective policy to be `board_or_agents` — for addressed and unaddressed interactions alike — and addressed interactions further restrict agent resolution to their `addresseeAgentId`. Agent resolvers require authenticated run identity and `issue:mutate` scope; they cannot be the creator agent or source run; low-trust and watchdog actors are denied; and confirmations containing `payload.toolAction` are always board-only. Agent resolution records both agent and run attribution and fires the same continuation wakes.
|
||||
Board users can resolve all interactions. Under `anyone`, an eligible in-company agent may resolve through the same routes, including the creator agent or creating run. `not_creator` excludes those creators, and `human_only` excludes agents. Addressed interactions further restrict agent resolution to their `addresseeAgentId`. Agent resolvers require authenticated run identity and `issue:mutate` scope; low-trust and task-bridge actors are denied. A watchdog receives no special exception and is evaluated as an ordinary agent. Confirmations containing `payload.toolAction` are always `human_only`. Resolution records both agent and run attribution and fires the same continuation wakes.
|
||||
|
||||
Resolving a card records the response only. Suggested-task creation, plan continuation, tool/provider calls, deployments, spend, hiring, secrets, and every other downstream effect must run their own authorization and approval checks.
|
||||
|
||||
The creator agent or a board user may withdraw a pending interaction. Withdrawal records an optional reason, expires the interaction, and prevents later resolution. Low-trust and task-watchdog agent runs cannot withdraw interactions.
|
||||
|
||||
|
||||
+63
-5
@@ -22,6 +22,7 @@ GET /api/agents/me/secrets
|
||||
"secrets": [
|
||||
{
|
||||
"key": "github_token",
|
||||
"secretRef": "11111111-1111-4111-8111-111111111111",
|
||||
"name": "GitHub token",
|
||||
"description": null,
|
||||
"delivery": "env",
|
||||
@@ -34,9 +35,11 @@ GET /api/agents/me/secrets
|
||||
}
|
||||
```
|
||||
|
||||
`delivery` is `env`, `api`, or `both`. The list never returns values, secret
|
||||
IDs, binding IDs, or config paths. An `env.*` binding implies read access through
|
||||
this API; an `access.*` binding grants API access without environment injection.
|
||||
`delivery` is `env`, `api`, or `both`. `secretRef` is a stable opaque handle,
|
||||
not secret material or a capability; every consuming route re-authorizes it.
|
||||
The list never returns values, the internal `secretId` field, binding IDs, or
|
||||
config paths. An `env.*` binding implies read access through this API; an
|
||||
`access.*` binding grants API access without environment injection.
|
||||
|
||||
Fetch a value only when it is needed. The request has no body and the response
|
||||
uses `Cache-Control: no-store`:
|
||||
@@ -60,6 +63,61 @@ successful or failed value fetch is audited in both `secret_access_events` and
|
||||
`activity_log`; agents must not log or paste fetched values into issues,
|
||||
comments, or documents.
|
||||
|
||||
## Agent Secret Proposals
|
||||
|
||||
These routes use the same current run-bound agent JWT as the list and fetch
|
||||
routes:
|
||||
|
||||
```
|
||||
POST /api/agents/me/secret-proposals
|
||||
GET /api/agents/me/secret-proposals
|
||||
DELETE /api/agents/me/secret-proposals/{proposalId}
|
||||
```
|
||||
|
||||
An agent can ask Paperclip to bind an existing secret under a new path without
|
||||
knowing a secret ID. Set `kind` to `binding` and identify the source by the
|
||||
agent's own existing `env.*` or `access.*` config path:
|
||||
|
||||
```json
|
||||
POST /api/agents/me/secret-proposals
|
||||
{
|
||||
"kind": "binding",
|
||||
"sourceConfigPath": "access.openai_api_key",
|
||||
"configPath": "access.evals_openai_api_key",
|
||||
"justification": "Use the existing OpenAI credential under the eval-specific alias"
|
||||
}
|
||||
```
|
||||
|
||||
`sourceConfigPath` must resolve from the proposing agent's own binding. An
|
||||
unknown path or another agent's path returns `404`. A binding request must
|
||||
provide exactly one of `sourceConfigPath`, `secretId`, or `secretProposalId`.
|
||||
Omit `targetAgentId` to target the proposing agent; under the default policy a
|
||||
manager may instead target one of its reports. `configPath` accepts
|
||||
`env.<KEY>` for environment injection or `access.<ALIAS>` for API-only access.
|
||||
|
||||
For a run with a checked-out origin issue, a successful proposal automatically
|
||||
creates a human-only **Confirm secret binding** card in that issue. API clients
|
||||
must not create a second interaction. The card contains only non-secret
|
||||
metadata: the source label, target agent, new config path, justification, and
|
||||
expiry.
|
||||
|
||||
Selecting **Create binding** accepts the card and then triggers a separate,
|
||||
freshly authorized binding write. Card acceptance is not execution. Read
|
||||
`result.secretProposal.status` for the actual outcome:
|
||||
|
||||
- `executed` means the binding write completed.
|
||||
- `failed` means the card was accepted but execution failed. The card renders
|
||||
**FAILED**, exposes a non-secret `errorCode`, and Paperclip posts a **Secret
|
||||
binding execution failed** comment with `Binding created: no`.
|
||||
- `rejected`, `withdrawn`, or `expired` means no binding was created.
|
||||
|
||||
The card wakes the issue assignee after resolution. The wake payload includes
|
||||
`secretProposal.configPath`, `decision`, `executionStatus`, and instructions.
|
||||
After any secret card, call `GET /api/agents/me/secrets` again and confirm the
|
||||
expected secret metadata and delivery before using the new binding. Acceptance
|
||||
is not execution; a failed wake or missing metadata means the alias must be
|
||||
treated as unavailable until a fresh proposal executes successfully.
|
||||
|
||||
## List Secrets
|
||||
|
||||
```
|
||||
@@ -114,7 +172,7 @@ credentials must not be stored in Paperclip `company_secrets`.
|
||||
The equivalent CLI check is:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets doctor --company-id {companyId}
|
||||
npx paperclipai secrets doctor --company-id {companyId}
|
||||
```
|
||||
|
||||
## Provider Vaults
|
||||
@@ -485,7 +543,7 @@ as declarations in the package manifest. Exports omit secret values, secret IDs,
|
||||
provider references, and encrypted provider material. Use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets declarations --company-id {companyId}
|
||||
npx paperclipai secrets declarations --company-id {companyId}
|
||||
```
|
||||
|
||||
to inspect the declarations that an export would emit before moving a package.
|
||||
@@ -9,39 +9,39 @@ Client-side commands for managing issues, agents, approvals, and more.
|
||||
|
||||
```sh
|
||||
# List issues
|
||||
pnpm paperclipai issue list [--status todo,in_progress] [--assignee-agent-id <id>] [--match text]
|
||||
npx paperclipai issue list [--status todo,in_progress] [--assignee-agent-id <id>] [--match text]
|
||||
|
||||
# Get issue details
|
||||
pnpm paperclipai issue get <issue-id-or-identifier>
|
||||
npx paperclipai issue get <issue-id-or-identifier>
|
||||
|
||||
# Create issue
|
||||
pnpm paperclipai issue create --title "..." [--description "..."] [--status todo] [--priority high]
|
||||
npx paperclipai issue create --title "..." [--description "..."] [--status todo] [--priority high]
|
||||
|
||||
# Update issue
|
||||
pnpm paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
|
||||
npx paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
|
||||
|
||||
# Add comment
|
||||
pnpm paperclipai issue comment <issue-id> --body "..." [--reopen]
|
||||
npx paperclipai issue comment <issue-id> --body "..." [--reopen]
|
||||
|
||||
# Checkout task
|
||||
pnpm paperclipai issue checkout <issue-id> --agent-id <agent-id>
|
||||
npx paperclipai issue checkout <issue-id> --agent-id <agent-id>
|
||||
|
||||
# Release task
|
||||
pnpm paperclipai issue release <issue-id>
|
||||
npx paperclipai issue release <issue-id>
|
||||
```
|
||||
|
||||
## Company Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclipai company list
|
||||
pnpm paperclipai company get <company-id>
|
||||
pnpm paperclipai company current [--company-id <company-id>]
|
||||
npx paperclipai company list
|
||||
npx paperclipai company get <company-id>
|
||||
npx paperclipai company current [--company-id <company-id>]
|
||||
|
||||
# Export to portable folder package (writes manifest + markdown files)
|
||||
pnpm paperclipai company export <company-id> --out ./exports/acme --include company,agents
|
||||
npx paperclipai company export <company-id> --out ./exports/acme --include company,agents
|
||||
|
||||
# Preview import (no writes)
|
||||
pnpm paperclipai company import \
|
||||
npx paperclipai company import \
|
||||
<owner>/<repo>/<path> \
|
||||
--target existing \
|
||||
--company-id <company-id> \
|
||||
@@ -50,7 +50,7 @@ pnpm paperclipai company import \
|
||||
--dry-run
|
||||
|
||||
# Apply import
|
||||
pnpm paperclipai company import \
|
||||
npx paperclipai company import \
|
||||
./exports/acme \
|
||||
--target new \
|
||||
--new-company-name "Acme Imported" \
|
||||
@@ -67,80 +67,80 @@ command.
|
||||
## Agent Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent list
|
||||
pnpm paperclipai agent get <agent-id>
|
||||
npx paperclipai agent list
|
||||
npx paperclipai agent get <agent-id>
|
||||
```
|
||||
|
||||
## Skills Commands
|
||||
|
||||
```sh
|
||||
# Browse app-shipped catalog skills without changing company state
|
||||
pnpm paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
|
||||
pnpm paperclipai skills search "pull request" [--json]
|
||||
npx paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
|
||||
npx paperclipai skills search "pull request" [--json]
|
||||
|
||||
# Inspect catalog metadata and file inventory before install
|
||||
pnpm paperclipai skills inspect github-pr-workflow
|
||||
npx paperclipai skills inspect github-pr-workflow
|
||||
|
||||
# Install a catalog skill into the company skill library
|
||||
# This does not attach the skill to any agent.
|
||||
pnpm paperclipai skills install github-pr-workflow --company-id <company-id>
|
||||
pnpm paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
|
||||
npx paperclipai skills install github-pr-workflow --company-id <company-id>
|
||||
npx paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
|
||||
|
||||
# External sources still use import instead of catalog install
|
||||
pnpm paperclipai skills import ./skills/my-skill --company-id <company-id>
|
||||
pnpm paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
|
||||
npx paperclipai skills import ./skills/my-skill --company-id <company-id>
|
||||
npx paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
|
||||
|
||||
# Attach desired company skills to an agent after install/import
|
||||
pnpm paperclipai skills agent sync <agent-id> --skill github-pr-workflow --mode add --company-id <company-id>
|
||||
npx paperclipai skills agent sync <agent-id> --skill github-pr-workflow --mode add --company-id <company-id>
|
||||
```
|
||||
|
||||
## Approval Commands
|
||||
|
||||
```sh
|
||||
# List approvals
|
||||
pnpm paperclipai approval list [--status pending]
|
||||
npx paperclipai approval list [--status pending]
|
||||
|
||||
# Get approval
|
||||
pnpm paperclipai approval get <approval-id>
|
||||
npx paperclipai approval get <approval-id>
|
||||
|
||||
# Create approval
|
||||
pnpm paperclipai approval create --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>]
|
||||
npx paperclipai approval create --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>]
|
||||
|
||||
# Approve
|
||||
pnpm paperclipai approval approve <approval-id> [--decision-note "..."]
|
||||
npx paperclipai approval approve <approval-id> [--decision-note "..."]
|
||||
|
||||
# Reject
|
||||
pnpm paperclipai approval reject <approval-id> [--decision-note "..."]
|
||||
npx paperclipai approval reject <approval-id> [--decision-note "..."]
|
||||
|
||||
# Request revision
|
||||
pnpm paperclipai approval request-revision <approval-id> [--decision-note "..."]
|
||||
npx paperclipai approval request-revision <approval-id> [--decision-note "..."]
|
||||
|
||||
# Resubmit
|
||||
pnpm paperclipai approval resubmit <approval-id> [--payload '{"..."}']
|
||||
npx paperclipai approval resubmit <approval-id> [--payload '{"..."}']
|
||||
|
||||
# Comment
|
||||
pnpm paperclipai approval comment <approval-id> --body "..."
|
||||
npx paperclipai approval comment <approval-id> --body "..."
|
||||
```
|
||||
|
||||
## Activity Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclipai activity list [--agent-id <id>] [--entity-type issue] [--entity-id <id>]
|
||||
npx paperclipai activity list [--agent-id <id>] [--entity-type issue] [--entity-id <id>]
|
||||
```
|
||||
|
||||
## Dashboard
|
||||
|
||||
```sh
|
||||
pnpm paperclipai dashboard get
|
||||
npx paperclipai dashboard get
|
||||
```
|
||||
|
||||
## Instance Settings
|
||||
|
||||
```sh
|
||||
pnpm paperclipai instance settings:general
|
||||
pnpm paperclipai instance settings:general:update --payload-json '{...}'
|
||||
pnpm paperclipai instance settings:experimental
|
||||
pnpm paperclipai instance settings:experimental:update --payload-json '{...}'
|
||||
npx paperclipai instance settings:general
|
||||
npx paperclipai instance settings:general:update --payload-json '{...}'
|
||||
npx paperclipai instance settings:experimental
|
||||
npx paperclipai instance settings:experimental:update --payload-json '{...}'
|
||||
```
|
||||
|
||||
Experimental features are opt-in and are provided without compatibility guarantees. They may break, change, or be removed at any time. Use them at your own risk.
|
||||
@@ -148,5 +148,5 @@ Experimental features are opt-in and are provided without compatibility guarante
|
||||
## Heartbeat
|
||||
|
||||
```sh
|
||||
pnpm paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100]
|
||||
npx paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100]
|
||||
```
|
||||
@@ -29,7 +29,7 @@ Company-scoped commands also accept `--company-id <id>`.
|
||||
For clean local instances, pass `--data-dir` on the command you run:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai run --data-dir ./tmp/paperclip-dev
|
||||
npx paperclipai run --data-dir ./tmp/paperclip-dev
|
||||
```
|
||||
|
||||
## Context Profiles
|
||||
@@ -38,7 +38,7 @@ Store defaults to avoid repeating flags:
|
||||
|
||||
```sh
|
||||
# Set defaults
|
||||
pnpm paperclipai context set --api-base http://localhost:3100 --company-id <id>
|
||||
npx paperclipai context set --api-base http://localhost:3100 --company-id <id>
|
||||
|
||||
# View current context
|
||||
pnpm paperclipai context show
|
||||
@@ -47,24 +47,24 @@ pnpm paperclipai context show
|
||||
pnpm paperclipai context list
|
||||
|
||||
# Switch profile
|
||||
pnpm paperclipai context use default
|
||||
npx paperclipai context use default
|
||||
```
|
||||
|
||||
To avoid storing secrets in context, use an env var:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
|
||||
npx paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
|
||||
export PAPERCLIP_API_KEY=...
|
||||
```
|
||||
|
||||
Secret operations are available under `paperclipai secrets`:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||
pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
npx paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
npx paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||
npx paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||
npx paperclipai secrets doctor --company-id <company-id>
|
||||
npx paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
```
|
||||
|
||||
Context is stored at `~/.paperclip/context.json`.
|
||||
|
||||
@@ -22,7 +22,7 @@ Does:
|
||||
Choose a specific instance:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai run --instance dev
|
||||
npx paperclipai run --instance dev
|
||||
```
|
||||
|
||||
## `paperclipai onboard`
|
||||
@@ -104,7 +104,7 @@ This now includes bind-oriented deployment settings such as `PAPERCLIP_BIND` and
|
||||
Allow a private hostname for authenticated/private mode:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai allowed-hostname my-tailscale-host
|
||||
npx paperclipai allowed-hostname my-tailscale-host
|
||||
```
|
||||
|
||||
## Local Storage Paths
|
||||
@@ -126,6 +126,6 @@ PAPERCLIP_HOME=/custom/home PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
|
||||
Or pass `--data-dir` directly on any command:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai run --data-dir ./tmp/paperclip-dev
|
||||
pnpm paperclipai doctor --data-dir ./tmp/paperclip-dev
|
||||
npx paperclipai run --data-dir ./tmp/paperclip-dev
|
||||
npx paperclipai doctor --data-dir ./tmp/paperclip-dev
|
||||
```
|
||||
@@ -42,7 +42,7 @@ pnpm paperclipai onboard
|
||||
Allow custom Tailscale hostnames:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai allowed-hostname my-machine
|
||||
npx paperclipai allowed-hostname my-machine
|
||||
```
|
||||
|
||||
### `authenticated` + `public`
|
||||
|
||||
@@ -70,7 +70,7 @@ pnpm dev --authenticated-private
|
||||
Allow additional private hostnames:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
||||
npx paperclipai allowed-hostname dotta-macbook-pro
|
||||
```
|
||||
|
||||
For full setup and troubleshooting, see [Tailscale Private Access](/deploy/tailscale-private-access).
|
||||
@@ -90,10 +90,10 @@ curl http://localhost:3100/api/companies
|
||||
For safer parallel local experiments, initialize a dedicated worktree instance instead of reusing your main checkout:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree:make local-lab --seed-mode minimal
|
||||
npx paperclipai worktree:make local-lab --seed-mode minimal
|
||||
cd ~/paperclip-local-lab
|
||||
pnpm paperclipai worktree env # inspect generated env exports
|
||||
eval "$(pnpm paperclipai worktree env)" # bash/zsh
|
||||
eval "$(npx paperclipai worktree env)" # bash/zsh
|
||||
pnpm paperclipai run
|
||||
pnpm paperclipai doctor
|
||||
```
|
||||
@@ -101,14 +101,15 @@ pnpm paperclipai doctor
|
||||
If the experiment gets noisy, repair or reseed the worktree without touching the main branch:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree repair --branch paperclip-local-lab
|
||||
pnpm paperclipai worktree reseed --from . --to paperclip-local-lab
|
||||
# worktree repair rebuilds the local checkout metadata, so run the checked-out CLI through the direct-exec form.
|
||||
node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts worktree repair --branch paperclip-local-lab
|
||||
npx paperclipai worktree reseed --from . --to paperclip-local-lab
|
||||
```
|
||||
|
||||
When done, shut it down and remove the isolated state explicitly:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree:cleanup local-lab --force
|
||||
npx paperclipai worktree:cleanup local-lab --force
|
||||
```
|
||||
|
||||
## Reset Dev Data
|
||||
|
||||
@@ -203,7 +203,7 @@ Validate secrets config:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai doctor
|
||||
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||
npx paperclipai secrets doctor --company-id <company-id>
|
||||
```
|
||||
|
||||
### Environment Overrides
|
||||
@@ -475,8 +475,8 @@ store.
|
||||
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id>
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
npx paperclipai secrets migrate-inline-env --company-id <company-id>
|
||||
npx paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
|
||||
# low-level script for direct database maintenance
|
||||
pnpm secrets:migrate-inline-env # dry run
|
||||
@@ -493,7 +493,7 @@ Company exports include only environment declarations. They do not include
|
||||
secret IDs, provider references, encrypted material, or plaintext values.
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
npx paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
```
|
||||
|
||||
Before importing a package into another instance, use those declarations to
|
||||
|
||||
@@ -67,7 +67,7 @@ http://my-macbook.tailnet.ts.net:3100
|
||||
If you access Paperclip with a custom private hostname, add it to the allowlist:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai allowed-hostname my-macbook.tailnet.ts.net
|
||||
npx paperclipai allowed-hostname my-macbook.tailnet.ts.net
|
||||
```
|
||||
|
||||
## 5. Verify the server is reachable
|
||||
|
||||
@@ -36,7 +36,7 @@ Shows a color-coded summary: vote counts, per-trace details with reasons, and ex
|
||||
paperclipai feedback report
|
||||
|
||||
# Point to a different server or company
|
||||
pnpm paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
|
||||
npx paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
|
||||
|
||||
# Include raw payload dumps in the report
|
||||
pnpm paperclipai feedback report --payloads
|
||||
@@ -112,7 +112,7 @@ Exports are full by default. `traces/` keeps the Paperclip envelope, while `full
|
||||
|
||||
```bash
|
||||
# Custom server and output directory
|
||||
pnpm paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
|
||||
npx paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
|
||||
```
|
||||
|
||||
### Reading an exported trace
|
||||
|
||||
@@ -23,7 +23,7 @@ The CLI exposes the same surface:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai instance settings:experimental
|
||||
pnpm paperclipai instance settings:experimental:update --payload-json '{...}'
|
||||
npx paperclipai instance settings:experimental:update --payload-json '{...}'
|
||||
```
|
||||
|
||||
Those commands change the same opt-in settings that the UI manages.
|
||||
|
||||
@@ -82,7 +82,7 @@ PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join
|
||||
- If Paperclip rejects the container-visible host with a hostname error, allow it from host:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai allowed-hostname host.docker.internal
|
||||
npx paperclipai allowed-hostname host.docker.internal
|
||||
```
|
||||
|
||||
Then restart Paperclip and rerun the smoke script.
|
||||
@@ -90,7 +90,7 @@ Then restart Paperclip and rerun the smoke script.
|
||||
- Authenticated/private mode: ensure hostnames are in the allowed list when required:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai allowed-hostname <host>
|
||||
npx paperclipai allowed-hostname <host>
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
+10
-10
@@ -90,16 +90,16 @@
|
||||
"rollup": ">=4.59.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"lexical": "0.46.0",
|
||||
"@lexical/clipboard": "0.46.0",
|
||||
"@lexical/link": "0.46.0",
|
||||
"@lexical/list": "0.46.0",
|
||||
"@lexical/markdown": "0.46.0",
|
||||
"@lexical/plain-text": "0.46.0",
|
||||
"@lexical/react": "0.46.0",
|
||||
"@lexical/rich-text": "0.46.0",
|
||||
"@lexical/selection": "0.46.0",
|
||||
"@lexical/utils": "0.46.0"
|
||||
"lexical": "0.49.0",
|
||||
"@lexical/clipboard": "0.49.0",
|
||||
"@lexical/link": "0.49.0",
|
||||
"@lexical/list": "0.49.0",
|
||||
"@lexical/markdown": "0.49.0",
|
||||
"@lexical/plain-text": "0.49.0",
|
||||
"@lexical/react": "0.49.0",
|
||||
"@lexical/rich-text": "0.49.0",
|
||||
"@lexical/selection": "0.49.0",
|
||||
"@lexical/utils": "0.49.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# @paperclipai/adapter-utils
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Allow the Paperclip host to route adapter sandbox-sync full-tree Git enumeration through its process-wide bounded scheduler.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -0,0 +1,876 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpRuntimeOptions } from "acpx/runtime";
|
||||
import type { AdapterExecutionContext, AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
startAdapterExecutionTargetProcessSessionBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
// This file is a characterization test. It pins the engine boundary's CURRENT
|
||||
// behavior; it never changes production code. Each test states the observed
|
||||
// contract of `executeAcpxEngine` as data, so a later refactor that alters an
|
||||
// exit path's result form, its finalization set, or its warm-lane decision
|
||||
// fails here.
|
||||
//
|
||||
// Adapter-boundary automatic CLI fallback note (File 1b): the codex, claude, and
|
||||
// gemini adapters wrap this engine and fall back to their CLI lane when an
|
||||
// AUTOMATIC ACP selection fails, but RETHROW when ACP was EXPLICITLY selected.
|
||||
// Those three adapters live in higher packages that depend on this one, so an
|
||||
// adapter-utils test cannot import them (a reverse dependency does not exist in
|
||||
// package.json). That branch already has REAL coverage in each adapter package:
|
||||
// - packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts
|
||||
// - packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts
|
||||
// - packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts
|
||||
// and each adapter's `normalizeEngine` / `resolveXxxExecutionEngineForRun`
|
||||
// classification (explicit acp/cli vs automatic acp) is pinned in each
|
||||
// package's acp.test.ts. This file pins the engine-level exit paths those
|
||||
// adapters wrap.
|
||||
|
||||
// Wrap the staging seam + both sandbox bridges in call-recording spies that
|
||||
// still delegate to the real implementations (a runner-backed sandbox test
|
||||
// exercises them end-to-end against a local runner). This lets the staging
|
||||
// tests assert the exact `runtimeRootDir`/`workspaceLocalDir`/`assets` the
|
||||
// engine threads without changing any real behavior for the other tests.
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
|
||||
return {
|
||||
...actual,
|
||||
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
|
||||
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
|
||||
};
|
||||
});
|
||||
import {
|
||||
createAcpxEngineExecutor,
|
||||
type AcpxEngineExecutorOptions,
|
||||
} from "./execute.js";
|
||||
import { runChildProcess } from "../server-utils.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function makeTempRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
// A remote run stages a process-session bridge whose detached event writer can
|
||||
// still be flushing a trailing event file into `.../process-sessions/<id>/events`
|
||||
// when the run's own best-effort `client.remove(sessionDir)` (which production
|
||||
// catch-wraps) has already returned. Under CI load that trailing write can land
|
||||
// between this recursive delete's directory snapshot and its `rmdir`, surfacing as
|
||||
// `ENOTEMPTY`. `maxRetries`/`retryDelay` make the cleanup ride out that window the
|
||||
// same way production tolerates it, instead of failing the just-passed test.
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) =>
|
||||
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function createLocalSandboxRunner(
|
||||
onExecute?: (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
}) => void,
|
||||
) {
|
||||
let counter = 0;
|
||||
return {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}) => {
|
||||
counter += 1;
|
||||
onExecute?.(input);
|
||||
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
||||
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
env: input.env ?? {},
|
||||
stdin: input.stdin,
|
||||
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
||||
graceSec: 5,
|
||||
onLog: input.onLog ?? (async () => {}),
|
||||
onSpawn: input.onSpawn
|
||||
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntime(
|
||||
onSetConfigOption?: (input: { key: string; value: string }) => void,
|
||||
onEnsureSession?: (input: Record<string, unknown>) => void,
|
||||
) {
|
||||
return {
|
||||
ensureSession: async (input: Record<string, unknown>) => {
|
||||
onEnsureSession?.(input);
|
||||
return ({
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
});
|
||||
},
|
||||
startTurn: () => ({
|
||||
events: (async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
setConfigOption: async (input: { key: string; value: string }) => {
|
||||
onSetConfigOption?.(input);
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
async function runExecutor(
|
||||
config: Record<string, unknown>,
|
||||
options: {
|
||||
context?: Record<string, unknown>;
|
||||
executionTransport?: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
executionTarget?: Record<string, unknown>;
|
||||
runtimeMcp?: AdapterRuntimeMcpAccess;
|
||||
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
|
||||
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
|
||||
} = {},
|
||||
) {
|
||||
const runtimeOptions: Record<string, unknown>[] = [];
|
||||
const configOptions: Array<{ key: string; value: string }> = [];
|
||||
const sessionInputs: Record<string, unknown>[] = [];
|
||||
const meta: Record<string, unknown>[] = [];
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
||||
const execute = createAcpxEngineExecutor({
|
||||
...(options.prepareRemoteManagedHome
|
||||
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
|
||||
: {}),
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions.push(options as unknown as Record<string, unknown>);
|
||||
return buildRuntime(
|
||||
({ key, value }) => configOptions.push({ key, value }),
|
||||
(input) => sessionInputs.push(input),
|
||||
) as never;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
},
|
||||
runtime: {},
|
||||
config,
|
||||
context: options.context ?? {},
|
||||
executionTransport: options.executionTransport,
|
||||
authToken: options.authToken,
|
||||
executionTarget: options.executionTarget,
|
||||
runtimeMcp: options.runtimeMcp,
|
||||
startupTraceContext: options.startupTraceContext,
|
||||
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
||||
logs.push({ stream, text });
|
||||
},
|
||||
onMeta: async (payload: unknown) => {
|
||||
meta.push(payload as Record<string, unknown>);
|
||||
},
|
||||
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
||||
events.push(event);
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result };
|
||||
}
|
||||
|
||||
// A remote sandbox setup that stages the host worktree through the real local
|
||||
// runner, so a run reaches the post-build window with live bridges and a held
|
||||
// staging lease.
|
||||
async function setupRemoteSandbox() {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const localCwd = path.join(root, "worktree");
|
||||
const remoteCwd = path.join(root, "remote-workspace");
|
||||
await fs.mkdir(localCwd, { recursive: true });
|
||||
await fs.mkdir(remoteCwd, { recursive: true });
|
||||
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
||||
const executionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "fake-plugin",
|
||||
remoteCwd,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
||||
}
|
||||
|
||||
const okHandle = {
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
};
|
||||
|
||||
function completedTurn() {
|
||||
return {
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function throwingTurn() {
|
||||
return {
|
||||
events: (async function* () {
|
||||
throw new Error("turn upstream boom");
|
||||
})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// A context whose only prompt-read field throws when read, so the prompt build
|
||||
// fails after the session handshake succeeds and before the turn starts.
|
||||
function throwingHandoffContext(): Record<string, unknown> {
|
||||
const context: Record<string, unknown> = {};
|
||||
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error("prompt build boom");
|
||||
},
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
// Stub both sandbox bridges with stop spies collected per start, so a test can
|
||||
// assert the bridges stopped without running the real bridge transport.
|
||||
function stubBridges() {
|
||||
const paperclipStops: Array<ReturnType<typeof vi.fn>> = [];
|
||||
const processStops: Array<ReturnType<typeof vi.fn>> = [];
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => {
|
||||
const stop = vi.fn(async () => {});
|
||||
paperclipStops.push(stop);
|
||||
return { env: {}, stop } as never;
|
||||
});
|
||||
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => {
|
||||
const stop = vi.fn(async () => {});
|
||||
processStops.push(stop);
|
||||
return { agentCommand: null, stop } as never;
|
||||
});
|
||||
const anyStopped = (stops: Array<ReturnType<typeof vi.fn>>) =>
|
||||
stops.some((stop) => stop.mock.calls.length > 0);
|
||||
const stoppedCount = (stops: Array<ReturnType<typeof vi.fn>>) =>
|
||||
stops.filter((stop) => stop.mock.calls.length > 0).length;
|
||||
return { paperclipStops, processStops, anyStopped, stoppedCount };
|
||||
}
|
||||
|
||||
function remoteArgs(
|
||||
stateDir: string,
|
||||
localCwd: string,
|
||||
executionTarget: unknown,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
context: {},
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("composed ACPX run: engine-boundary result form per exit path", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns (never throws) a create_runtime error result when the post-build runtime construction fails", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () => {
|
||||
throw new Error("createRuntime boom");
|
||||
},
|
||||
});
|
||||
|
||||
// The engine RETURNS a settled error result on this path; it does not throw.
|
||||
const result = await execute({
|
||||
runId: "boundary-create-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.signal).toBe(null);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.resultJson?.phase).toBe("create_runtime");
|
||||
expect(result.summary).toContain("createRuntime boom");
|
||||
});
|
||||
|
||||
it("returns an ensure_session error result when the handshake fails", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => {
|
||||
throw new Error("ensureSession boom");
|
||||
},
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "boundary-ensure-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("ensure_session");
|
||||
});
|
||||
|
||||
it("returns a runtime-error result with no clearSession and no errorMeta when ensureSession yields no handle", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
// A runtime that returns no session handle drives the missing-handle path.
|
||||
ensureSession: async () => undefined,
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "boundary-missing-handle",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("ensure_session");
|
||||
expect(result.errorCode).toBe("acpx_runtime_error");
|
||||
// The missing-handle result carries neither clearSession nor errorMeta, unlike
|
||||
// the other pre-turn error paths.
|
||||
expect(result.clearSession).toBeUndefined();
|
||||
expect(result.errorMeta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a configure_session error result that carries the requested session fields", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
// A custom agent with a requested model applies a session config option;
|
||||
// a throwing setConfigOption drives the configure_session failure path.
|
||||
setConfigOption: async () => {
|
||||
throw new Error("setConfigOption boom");
|
||||
},
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "boundary-configure-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
model: "custom-model-x",
|
||||
stateDir: path.join(root, "state"),
|
||||
},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("configure_session");
|
||||
// The configure_session result echoes the requested session identity fields.
|
||||
expect(result.resultJson).toHaveProperty("agent");
|
||||
expect(result.resultJson?.requestedModel).toBe("custom-model-x");
|
||||
expect(result.resultJson).toHaveProperty("requestedThinkingEffort");
|
||||
expect(result.resultJson).toHaveProperty("fastMode");
|
||||
});
|
||||
|
||||
it("returns a prepare_turn error result when the prompt build fails before the turn starts", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "boundary-prepare-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: throwingHandoffContext(),
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("prepare_turn");
|
||||
});
|
||||
|
||||
it("returns a turn error result with acpx_turn_failed when the running turn fails", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => throwingTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "boundary-turn-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("turn");
|
||||
expect(result.errorCode).toBe("acpx_turn_failed");
|
||||
});
|
||||
|
||||
it("returns the terminal turn-success shape (the one non-error-shaped return)", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () => buildRuntime() as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "boundary-success",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
// A completed, non-timed-out turn returns exitCode 0, no signal, no errorCode,
|
||||
// and the session identity fields, with resultJson.status = "completed".
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.signal).toBe(null);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.errorCode).toBe(null);
|
||||
expect(result.resultJson?.status).toBe("completed");
|
||||
expect(result.sessionId).toBe("backend-session");
|
||||
expect(result.sessionDisplayId).toBe("agent-session");
|
||||
expect(result.sessionParams).toBeTruthy();
|
||||
});
|
||||
|
||||
it("THROWS (does not return) when buildRuntime fails before it settles", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
// A staging failure throws inside buildRuntime before it settles, so the engine
|
||||
// rethrows through the `if (!buildRuntimeSettled) throw err` guard. This is one
|
||||
// of exactly two engine-boundary throw paths.
|
||||
vi.mocked(prepareAdapterExecutionTargetRuntime).mockImplementationOnce(async () => {
|
||||
throw new Error("staging boom");
|
||||
});
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => buildRuntime() as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
execute({
|
||||
runId: "boundary-build-throw",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never),
|
||||
).rejects.toThrow("staging boom");
|
||||
});
|
||||
|
||||
it("THROWS (does not return) on a partial bridge failure and stops the started sibling bridge once", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
// The paperclip bridge fails while the concurrently-started process-session
|
||||
// bridge resolves a live handle. This is the second engine-boundary throw path;
|
||||
// the abandon path must stop the started sibling so no bridge leaks.
|
||||
const stop = vi.fn(async () => {});
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
|
||||
throw new Error("paperclip bridge boom");
|
||||
});
|
||||
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
||||
async () => ({ agentCommand: null, stop }) as never,
|
||||
);
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => buildRuntime() as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
execute({
|
||||
runId: "boundary-bridge-throw",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never),
|
||||
).rejects.toThrow("paperclip bridge boom");
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composed ACPX run: finalization set fires exactly once per exit path", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("stops both bridges once and releases the staging lease on every exit path", async () => {
|
||||
const scenarios: Array<{
|
||||
name: string;
|
||||
createRuntime: AcpxEngineExecutorOptions["createRuntime"];
|
||||
config?: Record<string, unknown>;
|
||||
context?: Record<string, unknown>;
|
||||
}> = [
|
||||
{
|
||||
name: "create_runtime",
|
||||
createRuntime: () => {
|
||||
throw new Error("createRuntime boom");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ensure_session",
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => {
|
||||
throw new Error("ensure boom");
|
||||
},
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
},
|
||||
{
|
||||
name: "configure_session",
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
setConfigOption: async () => {
|
||||
throw new Error("config boom");
|
||||
},
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", model: "custom-model-x" },
|
||||
},
|
||||
{
|
||||
name: "prepare_turn",
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
context: throwingHandoffContext(),
|
||||
},
|
||||
{
|
||||
name: "turn",
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => throwingTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const { paperclipStops, processStops, stoppedCount } = stubBridges();
|
||||
const stagingLocks = new Map<string, Promise<unknown>>();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
stagingLocks,
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
createRuntime: scenario.createRuntime,
|
||||
});
|
||||
|
||||
const overrides: Record<string, unknown> = {};
|
||||
if (scenario.config) {
|
||||
overrides.config = {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
cwd: localCwd,
|
||||
...scenario.config,
|
||||
};
|
||||
}
|
||||
if (scenario.context) overrides.context = scenario.context;
|
||||
|
||||
await execute({
|
||||
runId: `finalize-${scenario.name}`,
|
||||
...remoteArgs(stateDir, localCwd, executionTarget, overrides),
|
||||
} as never).catch(() => {});
|
||||
|
||||
// The finalization set fires exactly once: each bridge stops once and the
|
||||
// per-session staging lease releases, so the lock map never strands the next
|
||||
// same-session run.
|
||||
expect(stoppedCount(paperclipStops), `paperclip bridge must stop once on ${scenario.name}`).toBe(1);
|
||||
expect(stoppedCount(processStops), `process-session bridge must stop once on ${scenario.name}`).toBe(1);
|
||||
expect(stagingLocks.size, `lease must release on ${scenario.name}`).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not re-run the turn teardown when the result mapping throws after the close", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const { paperclipStops, processStops, stoppedCount } = stubBridges();
|
||||
let closeCount = 0;
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
})(),
|
||||
// A completed turn whose result mapping throws after the close is read.
|
||||
result: Promise.resolve({
|
||||
status: "completed",
|
||||
get stopReason(): string {
|
||||
throw new Error("mapping boom");
|
||||
},
|
||||
}),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
close: async () => {
|
||||
closeCount += 1;
|
||||
},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: "finalize-map-throw",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never).catch(() => {});
|
||||
|
||||
// The completed turn closed the runtime once; the mapping throw did not re-run
|
||||
// the teardown through the turn catch.
|
||||
expect(closeCount).toBe(1);
|
||||
expect(stoppedCount(paperclipStops)).toBe(1);
|
||||
expect(stoppedCount(processStops)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composed ACPX run: host-lane warm handle set", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("closes and relaunches a persistent local runtime, so the next compatible run re-creates it", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
let createCount = 0;
|
||||
const warmHandles = new Map();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles,
|
||||
createRuntime: () => {
|
||||
createCount += 1;
|
||||
return buildRuntime() as never;
|
||||
},
|
||||
});
|
||||
const config = {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
mode: "persistent",
|
||||
warmHandleIdleMs: 60_000,
|
||||
};
|
||||
|
||||
const first = await execute({
|
||||
runId: "warm-save-1",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never);
|
||||
expect(first.exitCode).toBe(0);
|
||||
// Amendment B: a persistent local completed turn closes and relaunches the
|
||||
// runtime instead of warm-saving it (the run-minted API key is never revoked).
|
||||
expect(warmHandles.size).toBe(0);
|
||||
expect(createCount).toBe(1);
|
||||
|
||||
const second = await execute({
|
||||
runId: "warm-save-2",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: { sessionParams: first.sessionParams },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never);
|
||||
expect(second.exitCode).toBe(0);
|
||||
// No warm runtime survived, so the compatible second run re-creates it.
|
||||
expect(createCount).toBe(2);
|
||||
});
|
||||
|
||||
it("closes (does not warm-save) a completed non-persistent runtime, so the next run re-creates", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
let createCount = 0;
|
||||
let closeCount = 0;
|
||||
const warmHandles = new Map();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles,
|
||||
createRuntime: () => {
|
||||
createCount += 1;
|
||||
return {
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
setConfigOption: async () => {},
|
||||
close: async () => {
|
||||
closeCount += 1;
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
// No persistent mode, so the completed turn closes the runtime instead of
|
||||
// warm-saving it.
|
||||
const config = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir };
|
||||
|
||||
const first = await execute({
|
||||
runId: "warm-none-1",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never);
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(warmHandles.size).toBe(0);
|
||||
expect(closeCount).toBe(1);
|
||||
|
||||
const second = await execute({
|
||||
runId: "warm-none-2",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: { sessionParams: first.sessionParams },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never);
|
||||
expect(second.exitCode).toBe(0);
|
||||
// No warm handle survived, so the second run constructs a fresh runtime.
|
||||
expect(createCount).toBe(2);
|
||||
});
|
||||
|
||||
it("runs a fresh acp.handshake on the second run and re-creates the runtime (no warm reuse)", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
let createCount = 0;
|
||||
const warmHandles = new Map();
|
||||
const secondEvents: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles,
|
||||
createRuntime: () => {
|
||||
createCount += 1;
|
||||
return buildRuntime() as never;
|
||||
},
|
||||
});
|
||||
const config = {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
mode: "persistent",
|
||||
warmHandleIdleMs: 60_000,
|
||||
};
|
||||
const first = await execute({
|
||||
runId: "warm-hit-1",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never);
|
||||
|
||||
await execute({
|
||||
runId: "warm-hit-2",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: { sessionParams: (first as { sessionParams?: unknown }).sessionParams },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
||||
secondEvents.push(event);
|
||||
},
|
||||
} as never);
|
||||
|
||||
// Amendment B: the first run closed and relaunched, so no warm handle survives.
|
||||
// The second run runs a fresh acp.handshake (outcome ok, not skipped) and
|
||||
// re-creates the runtime.
|
||||
const handshakeEvents = secondEvents.filter(
|
||||
(event) => event.eventType === "run.startup.step" && event.payload?.step === "acp.handshake",
|
||||
);
|
||||
expect(handshakeEvents).toHaveLength(1);
|
||||
expect(handshakeEvents[0]!.payload?.outcome).not.toBe("skipped");
|
||||
expect(createCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Identity split and launch-environment finalization tests (Phase 17).
|
||||
//
|
||||
// Two tests are compile-time: the real assertion is that this file typechecks.
|
||||
// A `@ts-expect-error` marks an assignment that MUST fail; if it starts to
|
||||
// compile, tsc reports the unused directive and the typecheck fails. Each
|
||||
// compile-time assertion lives in a declared, unexecuted function, so vitest
|
||||
// never runs a synthetic value.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSessionFingerprint, finalizeLaunchEnvironment } from "./execute.js";
|
||||
import type {
|
||||
LaunchEnvironment,
|
||||
RunScopedContribution,
|
||||
RunSiteReuseCandidate,
|
||||
SessionFingerprintIdentity,
|
||||
} from "./run-contracts.js";
|
||||
|
||||
// A complete fingerprint identity with representative values for the 17 fields.
|
||||
const SAMPLE_FINGERPRINT_IDENTITY: SessionFingerprintIdentity = {
|
||||
acpxAgent: "claude",
|
||||
agentCommand: "claude",
|
||||
cwd: "/work",
|
||||
mode: "persistent",
|
||||
permissionMode: "approve-all",
|
||||
nonInteractivePermissions: "deny",
|
||||
requestedModel: "",
|
||||
requestedThinkingEffort: "",
|
||||
fastMode: false,
|
||||
remoteExecutionIdentity: null,
|
||||
additionalSourcesIdentity: {},
|
||||
skillsIdentity: {},
|
||||
skillPromptInstructions: "",
|
||||
paperclipClaudeSettings: null,
|
||||
mcpServers: [],
|
||||
secretManifestHash: "0000",
|
||||
adapterEnvHash: "0000",
|
||||
};
|
||||
|
||||
describe("acpx identity split and launch environment", () => {
|
||||
it("test_fingerprint_builder_accepts_only_session_fingerprint_identity", () => {
|
||||
// The builder accepts a fingerprint identity and returns a stable hash.
|
||||
const first = buildSessionFingerprint(SAMPLE_FINGERPRINT_IDENTITY);
|
||||
const second = buildSessionFingerprint(SAMPLE_FINGERPRINT_IDENTITY);
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(/^[0-9a-f]{16}$/);
|
||||
|
||||
// The compile-time assertions live in an unexecuted function.
|
||||
function _assertOnlyIdentity(): void {
|
||||
// An outer-key field is not a fingerprint field, so the builder rejects it.
|
||||
// @ts-expect-error companyId is not a SessionFingerprintIdentity field.
|
||||
buildSessionFingerprint({ ...SAMPLE_FINGERPRINT_IDENTITY, companyId: "c" });
|
||||
// @ts-expect-error taskKey is not a SessionFingerprintIdentity field.
|
||||
buildSessionFingerprint({ ...SAMPLE_FINGERPRINT_IDENTITY, taskKey: "t" });
|
||||
}
|
||||
void _assertOnlyIdentity;
|
||||
});
|
||||
|
||||
it("test_finalize_launch_environment_is_sole_constructor_of_branded_environment", () => {
|
||||
// The finalizer merges every contribution and freezes the launch env.
|
||||
const baseEnv: Record<string, string> = { LAUNCH_ENV_TEST_BASE: "base" };
|
||||
const launchEnvironment = finalizeLaunchEnvironment(baseEnv, [
|
||||
{ scope: "session", env: { LAUNCH_ENV_TEST_CONTRIB: "contrib" } },
|
||||
]);
|
||||
expect(launchEnvironment.env.LAUNCH_ENV_TEST_BASE).toBe("base");
|
||||
expect(launchEnvironment.env.LAUNCH_ENV_TEST_CONTRIB).toBe("contrib");
|
||||
expect(Object.isFrozen(launchEnvironment.env)).toBe(true);
|
||||
|
||||
// No plain object literal can stand in for the branded environment; it lacks
|
||||
// the private brand that only `finalizeLaunchEnvironment` sets.
|
||||
function _assertBrand(): void {
|
||||
// @ts-expect-error missing the private launch-environment brand.
|
||||
const _fake: LaunchEnvironment = { env: {} };
|
||||
void _fake;
|
||||
}
|
||||
void _assertBrand;
|
||||
});
|
||||
|
||||
it("test_run_scoped_contribution_is_not_assignable_to_a_reuse_payload", () => {
|
||||
// A run-scoped contribution and the branded launch environment must never
|
||||
// enter a reuse payload (Amendment B). The reuse candidate types reject both.
|
||||
function _assertNotReusable(
|
||||
contribution: RunScopedContribution,
|
||||
launchEnvironment: LaunchEnvironment,
|
||||
): void {
|
||||
// @ts-expect-error a run-scoped contribution is not a reuse candidate.
|
||||
const _a: RunSiteReuseCandidate = contribution;
|
||||
// @ts-expect-error the branded launch environment is not a reuse candidate.
|
||||
const _b: RunSiteReuseCandidate = launchEnvironment;
|
||||
void _a;
|
||||
void _b;
|
||||
}
|
||||
void _assertNotReusable;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -336,7 +336,7 @@ const ALLOWED_TURN_SPAN_ATTRIBUTE_KEYS = new Set<string>([
|
||||
]);
|
||||
|
||||
describe("shared ACPX engine runtime behavior", () => {
|
||||
it("persists ACP agent process identity before prompting and reuses it for the next warm heartbeat", async () => {
|
||||
it("persists ACP agent process identity before prompting on each run (host lane re-creates, no warm reuse)", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const startedAt = "2026-07-30T07:00:00.000Z";
|
||||
const processPid = 43_210;
|
||||
@@ -419,7 +419,9 @@ describe("shared ACPX engine runtime behavior", () => {
|
||||
} as never);
|
||||
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(runtimeCreateCount).toBe(1);
|
||||
// Amendment B: the host lane closes and relaunches (no warm reuse), so the
|
||||
// second run re-creates the runtime and re-persists the process identity.
|
||||
expect(runtimeCreateCount).toBe(2);
|
||||
expect(secondOnSpawn).toHaveBeenCalledOnce();
|
||||
expect(turnStartedBeforeProcessIdentity).toBe(false);
|
||||
});
|
||||
@@ -3051,6 +3053,83 @@ describe("ACPX engine remote session-lifecycle re-staging (PR 3: stage once / re
|
||||
expect(disposedRunIds).toContain("run-a");
|
||||
});
|
||||
|
||||
// F5 leak window 1: a seam that throws AFTER a successful stage() must not leave
|
||||
// the fresh in-sandbox runtime behind. The engine records the staged result in
|
||||
// the stage wrapper and disposes it when the seam throws.
|
||||
it("test_seam_throw_after_stage_disposes_fresh_staged_runtime", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const stagedRuntimes = new Map();
|
||||
let capturedRuntimeRoot: string | null = null;
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes,
|
||||
createRuntime: () => recordingRuntime({ ensureInputs: [] }) as never,
|
||||
prepareRemoteManagedHome: async (input) => {
|
||||
const staged = await input.stage([]);
|
||||
capturedRuntimeRoot = staged.runtimeRootDir;
|
||||
// The seam fails AFTER a successful stage (e.g. an in-sandbox config
|
||||
// materialize step throws). The fresh in-sandbox runtime is already shipped.
|
||||
throw new Error("seam failed after stage");
|
||||
},
|
||||
});
|
||||
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
||||
|
||||
await expect(
|
||||
execute({ runId: "run-a", runtime: {}, ...base } as never),
|
||||
).rejects.toThrow("seam failed after stage");
|
||||
|
||||
// The stage shipped a real in-sandbox runtime root (local runner = host FS)...
|
||||
expect(typeof capturedRuntimeRoot).toBe("string");
|
||||
// ...and the seam throw disposed it, so the abandoned managed home is gone.
|
||||
expect(await pathExists(capturedRuntimeRoot!)).toBe(false);
|
||||
// The failed fresh stage was never cached for reuse.
|
||||
expect(stagedRuntimes.size).toBe(0);
|
||||
});
|
||||
|
||||
// F5 leak window 2: when a bridge fails during bring-up on a resume that BORROWED
|
||||
// the cached staged runtime, the rollback must remove the borrowed cache entry
|
||||
// through the same identity guard `discardStagedRuntime` uses — else a later
|
||||
// resume reuses an entry whose host staged-temp was already disposed.
|
||||
it("test_partial_bridge_rollback_removes_borrowed_staged_entry", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const ensureInputs: Array<Record<string, unknown>> = [];
|
||||
const stagedRuntimes = new Map();
|
||||
let disposeCalls = 0;
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes,
|
||||
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
|
||||
prepareRemoteManagedHome: async (input) => ({
|
||||
stagedRuntime: await input.stage([]),
|
||||
disposeStaged: async () => {
|
||||
disposeCalls += 1;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
||||
|
||||
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
|
||||
expect(first.exitCode).toBe(0);
|
||||
// The clean turn cached the staged runtime for reuse; nothing disposed yet.
|
||||
expect(stagedRuntimes.size).toBe(1);
|
||||
expect(disposeCalls).toBe(0);
|
||||
|
||||
// Run B resumes the same session and borrows the cached staged runtime, but a
|
||||
// bridge fails during bring-up.
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
|
||||
throw new Error("paperclip bridge boom");
|
||||
});
|
||||
await expect(
|
||||
execute({ runId: "run-b", runtime: { sessionParams: first.sessionParams }, ...base } as never),
|
||||
).rejects.toThrow("paperclip bridge boom");
|
||||
|
||||
// The rollback removed the borrowed cache entry through the identity guard, so a
|
||||
// later resume can never reuse an entry whose host staged-temp was disposed...
|
||||
expect(stagedRuntimes.size).toBe(0);
|
||||
// ...and it fired that entry's shared idempotent dispose exactly once.
|
||||
expect(disposeCalls).toBe(1);
|
||||
});
|
||||
|
||||
// Greptile P1 "Concurrent Runs Corrupt Cache Ownership": two overlapping runs
|
||||
// of the same session key must not ship into the same remote workspace at once.
|
||||
// The per-key staging lock serializes the stage-or-reuse section, so their
|
||||
@@ -3272,7 +3351,10 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
|
||||
|
||||
// A codex bring-up over the remote sandbox lane crosses all 7 boundaries.
|
||||
// Each boundary span parents to the sandbox bring-up span, not to the run
|
||||
// root or the turn span.
|
||||
// root or the turn span. The `stage.sync` step also opens three host
|
||||
// sub-step spans — `snapshot.git`, `snapshot.baseline`, and `pack` — around
|
||||
// its git enumeration, baseline content-hash walk, and workspace tarball
|
||||
// build, so those nest one level deeper.
|
||||
const childNames = spans
|
||||
.filter((span) => span !== runRootSpan && span !== startupSpan && span !== turnSpan)
|
||||
.map((span) => span.name)
|
||||
@@ -3283,15 +3365,37 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
|
||||
"bridge.paperclip",
|
||||
"bridge.process-session",
|
||||
"codex-home.seed",
|
||||
"pack",
|
||||
"skills.reconcile",
|
||||
"snapshot.baseline",
|
||||
"snapshot.git",
|
||||
"stage.sync",
|
||||
"workspace.resolve",
|
||||
],
|
||||
);
|
||||
|
||||
// Every boundary span parents to the sandbox bring-up span and ends.
|
||||
// The three host sub-step spans nest under the `stage.sync` step span (that
|
||||
// host work runs inside the step), not directly under the bring-up span.
|
||||
const stageSyncSpan = spans.find((span) => span.name === "stage.sync");
|
||||
const packSpan = spans.find((span) => span.name === "pack");
|
||||
const snapshotGitSpan = spans.find((span) => span.name === "snapshot.git");
|
||||
const snapshotBaselineSpan = spans.find((span) => span.name === "snapshot.baseline");
|
||||
expect(stageSyncSpan).toBeTruthy();
|
||||
expect(packSpan).toBeTruthy();
|
||||
expect(snapshotGitSpan).toBeTruthy();
|
||||
expect(snapshotBaselineSpan).toBeTruthy();
|
||||
expect(packSpan!.parent).toBe(stageSyncSpan);
|
||||
expect(snapshotGitSpan!.parent).toBe(stageSyncSpan);
|
||||
expect(snapshotBaselineSpan!.parent).toBe(stageSyncSpan);
|
||||
expect(packSpan!.ended).toBe(true);
|
||||
|
||||
// Every boundary step span parents to the sandbox bring-up span and ends.
|
||||
// The three `stage.sync` sub-step spans are the exceptions: they parent to
|
||||
// `stage.sync` above.
|
||||
const stageSyncChildren = new Set([packSpan, snapshotGitSpan, snapshotBaselineSpan]);
|
||||
for (const span of spans) {
|
||||
if (span === runRootSpan || span === startupSpan || span === turnSpan) continue;
|
||||
if (stageSyncChildren.has(span)) continue;
|
||||
expect(span.parent, `span "${span.name}" must parent to the startup span`).toBe(startupSpan);
|
||||
expect(span.ended, `span "${span.name}" must end`).toBe(true);
|
||||
}
|
||||
@@ -4280,7 +4384,7 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
|
||||
}
|
||||
});
|
||||
|
||||
it("emits a skipped acp.handshake event when a warm-handle hit skips the handshake", async () => {
|
||||
it("runs a fresh acp.handshake on a compatible second run (host lane re-creates, no warm hit)", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const warmHandles = new Map();
|
||||
@@ -4306,9 +4410,9 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never);
|
||||
// The second run reuses the warm handle, so the handshake does no work. It
|
||||
// must emit exactly one acp.handshake event with outcome = skipped and a
|
||||
// zero wall time, not a misleading zero-work `ok` event.
|
||||
// Amendment B: the first run closed and relaunched (no warm save), so the
|
||||
// compatible second run re-creates the runtime and runs a fresh handshake — one
|
||||
// acp.handshake event whose outcome is not `skipped`.
|
||||
await execute({
|
||||
runId: "run-warm-2",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
@@ -4326,8 +4430,7 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
|
||||
(event) => event.eventType === "run.startup.step" && event.payload?.step === "acp.handshake",
|
||||
);
|
||||
expect(handshakeEvents).toHaveLength(1);
|
||||
expect(handshakeEvents[0]!.payload?.outcome).toBe("skipped");
|
||||
expect(handshakeEvents[0]!.payload?.durationMs).toBe(0);
|
||||
expect(handshakeEvents[0]!.payload?.outcome).not.toBe("skipped");
|
||||
});
|
||||
|
||||
it("does not emit startup-step events on a local (non-sandbox) run except workspace.resolve", async () => {
|
||||
@@ -4352,3 +4455,789 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
|
||||
expect(emitted.has("bridge.process-session")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPX engine run lifecycle corrections (F1: settle every failure after buildRuntime)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// A remote sandbox setup that stages the host worktree through the real local
|
||||
// runner, so a run reaches the post-build window with live bridges and a held
|
||||
// staging lease.
|
||||
async function setupRemoteSandbox() {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const localCwd = path.join(root, "worktree");
|
||||
const remoteCwd = path.join(root, "remote-workspace");
|
||||
await fs.mkdir(localCwd, { recursive: true });
|
||||
await fs.mkdir(remoteCwd, { recursive: true });
|
||||
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
||||
const executionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "fake-plugin",
|
||||
remoteCwd,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
||||
}
|
||||
|
||||
it("test_runtime_create_failure_returns_error_result_with_phase_create_runtime", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () => {
|
||||
throw new Error("createRuntime boom");
|
||||
},
|
||||
});
|
||||
const result = await execute({
|
||||
runId: "run-create-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
// The post-build runtime-creation window failure returns a settled error
|
||||
// result instead of throwing.
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("create_runtime");
|
||||
expect(result.summary).toContain("createRuntime boom");
|
||||
});
|
||||
|
||||
it("test_runtime_create_failure_stops_bridges_and_releases_staging_lease", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const paperclipStop = vi.fn(async () => {});
|
||||
const processStop = vi.fn(async () => {});
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(
|
||||
async () => ({ env: {}, stop: paperclipStop }) as never,
|
||||
);
|
||||
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
||||
async () => ({ agentCommand: null, stop: processStop }) as never,
|
||||
);
|
||||
const stagingLocks = new Map<string, Promise<unknown>>();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
stagingLocks,
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
createRuntime: () => {
|
||||
throw new Error("createRuntime boom");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-create-fail-remote",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
context: {},
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("create_runtime");
|
||||
// Both live bridges stop exactly once.
|
||||
expect(paperclipStop).toHaveBeenCalledTimes(1);
|
||||
expect(processStop).toHaveBeenCalledTimes(1);
|
||||
// The per-session staging lease released, so the lock map does not strand the
|
||||
// next same-session run.
|
||||
expect(stagingLocks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("test_prompt_build_failure_returns_error_result_with_phase_prepare_turn", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const close = vi.fn(async () => {});
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => ({
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
}),
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
close,
|
||||
}) as never,
|
||||
});
|
||||
|
||||
// A throwing accessor on a field only `buildPrompt` reads makes the prompt
|
||||
// build fail after the session handshake succeeds.
|
||||
const context: Record<string, unknown> = {};
|
||||
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error("prompt build boom");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-prepare-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("prepare_turn");
|
||||
// The established runtime is closed on the pre-turn failure, so no session
|
||||
// leaks.
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("test_sandbox_startup_span_ends_exactly_once_on_every_exit_path", async () => {
|
||||
// Each scenario runs a fresh remote bring-up with a recording trace and asserts
|
||||
// the one sandbox.startup span ends exactly once, whatever the exit path.
|
||||
const failingEnsure = () =>
|
||||
({
|
||||
ensureSession: async () => {
|
||||
throw new Error("ensureSession boom");
|
||||
},
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
close: async () => {},
|
||||
}) as never;
|
||||
const scenarios: Array<{ name: string; createRuntime: AcpxEngineExecutorOptions["createRuntime"] }> = [
|
||||
{ name: "success", createRuntime: () => buildRuntime() as never },
|
||||
{
|
||||
name: "create_runtime failure",
|
||||
createRuntime: () => {
|
||||
throw new Error("createRuntime boom");
|
||||
},
|
||||
},
|
||||
{ name: "ensure_session failure", createRuntime: () => failingEnsure() },
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const { traceContext, spans } = createRecordingStartupTrace();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: scenario.createRuntime,
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: `run-span-${scenario.name.replace(/\s+/g, "-")}`,
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
context: {},
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget,
|
||||
startupTraceContext: traceContext,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never);
|
||||
|
||||
const startupSpans = spans.filter((span) => span.name === "sandbox.startup");
|
||||
expect(startupSpans, `scenario "${scenario.name}" must open one sandbox.startup span`).toHaveLength(1);
|
||||
expect(
|
||||
startupSpans[0]!.endCalls,
|
||||
`scenario "${scenario.name}" must end the sandbox.startup span exactly once`,
|
||||
).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPX engine run lifecycle corrections (F2: close the runtime for every pre-turn failure)", () => {
|
||||
const startedAt = "2026-01-01T00:00:00.000Z";
|
||||
const handle = {
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
};
|
||||
|
||||
it("test_persistent_host_failure_closes_recreated_runtime_no_warm_reuse", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const closeSpy = vi.fn(async () => {});
|
||||
let created = 0;
|
||||
const warmHandles = new Map();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles,
|
||||
createRuntime: (options) => {
|
||||
created += 1;
|
||||
const opts = options as AcpRuntimeOptions & {
|
||||
onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
};
|
||||
return {
|
||||
ensureSession: async () => {
|
||||
await opts.onAgentSpawn?.({ pid: 4242, startedAt });
|
||||
return handle;
|
||||
},
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
close: closeSpy,
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
const config = {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
mode: "persistent",
|
||||
warmHandleIdleMs: 60_000,
|
||||
};
|
||||
|
||||
const first = await execute({
|
||||
runId: "warm-handshake-1",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
} as never);
|
||||
expect(first.exitCode).toBe(0);
|
||||
// Amendment B: the first clean persistent turn closes and relaunches instead of
|
||||
// warm-saving, so no warm handle survives.
|
||||
expect(warmHandles.size).toBe(0);
|
||||
expect(created).toBe(1);
|
||||
|
||||
// The second run finds no warm handle, so it re-creates the runtime and fails
|
||||
// while persisting process identity.
|
||||
const second = await execute({
|
||||
runId: "warm-handshake-2",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: { sessionParams: first.sessionParams },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {
|
||||
throw new Error("onSpawn boom");
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(created).toBe(2);
|
||||
expect(second.exitCode).toBe(1);
|
||||
expect(second.resultJson?.phase).toBe("ensure_session");
|
||||
// Each run closed its own runtime (one per run); no warm entry survived.
|
||||
expect(closeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(warmHandles.size).toBe(0);
|
||||
});
|
||||
|
||||
it("test_missing_session_handle_closes_runtime", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const closeSpy = vi.fn(async () => {});
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
// A runtime that returns no session handle drives the missing-handle path.
|
||||
ensureSession: async () => undefined,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
close: closeSpy,
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "missing-handle",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("ensure_session");
|
||||
expect(result.errorCode).toBe("acpx_runtime_error");
|
||||
// The constructed runtime is closed so its child process cannot leak.
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("test_host_lane_closes_and_relaunches_every_run_and_leaves_no_warm_entry", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
let created = 0;
|
||||
const closes: number[] = [];
|
||||
const warmHandles = new Map();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles,
|
||||
createRuntime: (options) => {
|
||||
const id = (created += 1);
|
||||
const opts = options as AcpRuntimeOptions & {
|
||||
onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
};
|
||||
return {
|
||||
ensureSession: async () => {
|
||||
await opts.onAgentSpawn?.({ pid: 1000 + id, startedAt });
|
||||
return handle;
|
||||
},
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
close: async () => {
|
||||
closes.push(id);
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
const config = {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
mode: "persistent",
|
||||
warmHandleIdleMs: 60_000,
|
||||
};
|
||||
|
||||
const first = await execute({
|
||||
runId: "warm-timer-1",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
} as never);
|
||||
expect(first.exitCode).toBe(0);
|
||||
// Amendment B: the first clean persistent turn closes runtime #1 and leaves no
|
||||
// warm entry, so the host lane never keeps a live runtime warm.
|
||||
expect(warmHandles.size).toBe(0);
|
||||
expect(closes).toEqual([1]);
|
||||
|
||||
// The second run finds no warm entry, so it re-creates runtime #2 and fails
|
||||
// while persisting process identity; runtime #2 closes.
|
||||
const second = await execute({
|
||||
runId: "warm-timer-2",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: { sessionParams: first.sessionParams },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {
|
||||
throw new Error("onSpawn boom");
|
||||
},
|
||||
} as never);
|
||||
expect(created).toBe(2);
|
||||
expect(second.exitCode).toBe(1);
|
||||
expect(closes).toEqual([1, 2]);
|
||||
expect(warmHandles.size).toBe(0);
|
||||
|
||||
// A later heartbeat also finds no warm entry and constructs a fresh runtime.
|
||||
const third = await execute({
|
||||
runId: "warm-timer-3",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: { sessionParams: first.sessionParams },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
} as never);
|
||||
expect(third.exitCode).toBe(0);
|
||||
expect(created).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPX engine run lifecycle corrections (F3: one teardown error policy)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const okHandle = {
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
};
|
||||
|
||||
async function setupRemoteSandbox() {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const localCwd = path.join(root, "worktree");
|
||||
const remoteCwd = path.join(root, "remote-workspace");
|
||||
await fs.mkdir(localCwd, { recursive: true });
|
||||
await fs.mkdir(remoteCwd, { recursive: true });
|
||||
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
||||
const executionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "fake-plugin",
|
||||
remoteCwd,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
||||
}
|
||||
|
||||
// Stub both sandbox bridges with stop spies collected per start, so a test can
|
||||
// assert the bridges stopped without running the real bridge transport.
|
||||
function stubBridges() {
|
||||
const paperclipStops: Array<ReturnType<typeof vi.fn>> = [];
|
||||
const processStops: Array<ReturnType<typeof vi.fn>> = [];
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => {
|
||||
const stop = vi.fn(async () => {});
|
||||
paperclipStops.push(stop);
|
||||
return { env: {}, stop } as never;
|
||||
});
|
||||
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => {
|
||||
const stop = vi.fn(async () => {});
|
||||
processStops.push(stop);
|
||||
return { agentCommand: null, stop } as never;
|
||||
});
|
||||
const anyStopped = (stops: Array<ReturnType<typeof vi.fn>>) =>
|
||||
stops.some((stop) => stop.mock.calls.length > 0);
|
||||
const stoppedCount = (stops: Array<ReturnType<typeof vi.fn>>) =>
|
||||
stops.filter((stop) => stop.mock.calls.length > 0).length;
|
||||
return { paperclipStops, processStops, anyStopped, stoppedCount };
|
||||
}
|
||||
|
||||
function throwingHandoffContext(): Record<string, unknown> {
|
||||
const context: Record<string, unknown> = {};
|
||||
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error("prompt build boom");
|
||||
},
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
function completedTurn() {
|
||||
return {
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function throwingTurn() {
|
||||
return {
|
||||
events: (async function* () {
|
||||
throw new Error("turn upstream boom");
|
||||
})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function remoteArgs(
|
||||
stateDir: string,
|
||||
localCwd: string,
|
||||
executionTarget: unknown,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
context: {},
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("test_teardown_continues_after_one_teardown_step_fails", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const { paperclipStops, processStops, anyStopped } = stubBridges();
|
||||
const stagingLocks = new Map<string, Promise<unknown>>();
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const execute = createAcpxEngineExecutor({
|
||||
stagingLocks,
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => throwingTurn(),
|
||||
// A failing teardown step must not stop the later teardown steps.
|
||||
close: async () => {
|
||||
throw new Error("close boom");
|
||||
},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "td-continue",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget, {
|
||||
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
||||
logs.push({ stream, text });
|
||||
},
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
// The close failure did not stop the bridge stops or the lease release.
|
||||
expect(anyStopped(paperclipStops)).toBe(true);
|
||||
expect(anyStopped(processStops)).toBe(true);
|
||||
expect(stagingLocks.size).toBe(0);
|
||||
// The close failure was recorded, not silently dropped.
|
||||
expect(
|
||||
logs.some((entry) => entry.stream === "stderr" && entry.text.includes('teardown step "runtime-close" failed')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("test_staging_lease_releases_in_finally_on_every_exit_path", async () => {
|
||||
const scenarios: Array<{
|
||||
name: string;
|
||||
createRuntime: AcpxEngineExecutorOptions["createRuntime"];
|
||||
context: Record<string, unknown>;
|
||||
}> = [
|
||||
{
|
||||
name: "create_runtime",
|
||||
createRuntime: () => {
|
||||
throw new Error("createRuntime boom");
|
||||
},
|
||||
context: {},
|
||||
},
|
||||
{
|
||||
name: "ensure_session",
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => {
|
||||
throw new Error("ensure boom");
|
||||
},
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
context: {},
|
||||
},
|
||||
{
|
||||
name: "prepare_turn",
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
context: throwingHandoffContext(),
|
||||
},
|
||||
{
|
||||
name: "turn",
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => throwingTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
context: {},
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
context: {},
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const stagingLocks = new Map<string, Promise<unknown>>();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
stagingLocks,
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
createRuntime: scenario.createRuntime,
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: `lease-${scenario.name}`,
|
||||
...remoteArgs(stateDir, localCwd, executionTarget, { context: scenario.context }),
|
||||
} as never).catch(() => {});
|
||||
|
||||
// The staging lease is released in a finally on every exit path, so the lock
|
||||
// map never strands the next same-session run.
|
||||
expect(stagingLocks.size, `lease must release on the ${scenario.name} path`).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("test_result_emission_failure_does_not_skip_teardown", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const { paperclipStops, processStops, anyStopped } = stubBridges();
|
||||
const stagingLocks = new Map<string, Promise<unknown>>();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
stagingLocks,
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => throwingTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: "emit-fail",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget, {
|
||||
// Fail the acpx.error emission only, so teardown must still run.
|
||||
onLog: async (_stream: "stdout" | "stderr", text: string) => {
|
||||
if (text.includes('"type":"acpx.error"')) throw new Error("onLog boom");
|
||||
},
|
||||
}),
|
||||
} as never).catch(() => {});
|
||||
|
||||
expect(anyStopped(paperclipStops)).toBe(true);
|
||||
expect(anyStopped(processStops)).toBe(true);
|
||||
expect(stagingLocks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("test_result_mapping_throw_after_close_does_not_rerun_teardown", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const { paperclipStops, processStops, stoppedCount } = stubBridges();
|
||||
let closeCount = 0;
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
})(),
|
||||
// A completed turn whose result mapping throws after the close is read.
|
||||
result: Promise.resolve({
|
||||
status: "completed",
|
||||
get stopReason(): string {
|
||||
throw new Error("mapping boom");
|
||||
},
|
||||
}),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
close: async () => {
|
||||
closeCount += 1;
|
||||
},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: "map-throw",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never).catch(() => {});
|
||||
|
||||
// The completed turn closed the runtime once; the mapping throw did not re-run
|
||||
// the teardown through the turn catch.
|
||||
expect(closeCount).toBe(1);
|
||||
expect(stoppedCount(paperclipStops)).toBe(1);
|
||||
expect(stoppedCount(processStops)).toBe(1);
|
||||
});
|
||||
|
||||
it("test_teardown_failure_does_not_change_external_result", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => throwingTurn(),
|
||||
close: async () => {
|
||||
throw new Error("close boom");
|
||||
},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "td-result",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never);
|
||||
|
||||
// The exit cause (the turn failure) determines the external result; the failing
|
||||
// teardown step never leaks into it.
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("turn");
|
||||
expect(result.errorCode).toBe("acpx_turn_failed");
|
||||
expect(result.errorMessage).toContain("turn upstream boom");
|
||||
expect(result.errorMessage).not.toContain("close boom");
|
||||
});
|
||||
|
||||
it("test_flush_child_stderr_runs_on_every_exit_path", async () => {
|
||||
const writes: string[] = [];
|
||||
const spy = vi.spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
}) as never);
|
||||
try {
|
||||
const scenarios: Array<{ name: string; mode: string; context: Record<string, unknown> }> = [
|
||||
{ name: "ensure_session", mode: "ensure_fail", context: {} },
|
||||
{ name: "prepare_turn", mode: "success", context: throwingHandoffContext() },
|
||||
{ name: "turn", mode: "turn_fail", context: {} },
|
||||
{ name: "success", mode: "success", context: {} },
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const root = await makeTempRoot();
|
||||
const marker = `paperclip-flush-probe-${scenario.name}`;
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: (options) => {
|
||||
const opts = options as {
|
||||
onAgentStderr?: (chunk: string) => void;
|
||||
};
|
||||
// Emit a partial (newline-free) stderr chunk so it stays pending until a
|
||||
// teardown flush writes it to process.stderr.
|
||||
const emit = () => opts.onAgentStderr?.(marker);
|
||||
return {
|
||||
ensureSession: async () => {
|
||||
emit();
|
||||
if (scenario.mode === "ensure_fail") throw new Error("ensure boom");
|
||||
return okHandle;
|
||||
},
|
||||
startTurn: () =>
|
||||
scenario.mode === "turn_fail" ? throwingTurn() : completedTurn(),
|
||||
close: async () => {},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: `flush-${scenario.name}`,
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: scenario.context,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never).catch(() => {});
|
||||
|
||||
expect(
|
||||
writes.some((write) => write.includes(marker)),
|
||||
`flushChildStderr must run on the ${scenario.name} path`,
|
||||
).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,85 @@
|
||||
// Compile-time type tests for the run contracts.
|
||||
//
|
||||
// The real assertion in each test is that this file compiles. A `@ts-expect-error`
|
||||
// marks an assignment that MUST fail to typecheck; if it starts to compile, tsc
|
||||
// reports the unused directive and the typecheck fails. The runtime body only
|
||||
// keeps vitest happy, so each test names the property it proves.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
AcquiredRunResources,
|
||||
ConsumedRunResources,
|
||||
PreTurnFailedCause,
|
||||
ReadyRunResources,
|
||||
SessionFingerprintIdentity,
|
||||
TurnCause,
|
||||
} from "./run-contracts.js";
|
||||
|
||||
// A type-level equality check. `Equal<A, B>` is true only when A and B are the
|
||||
// same type.
|
||||
type Equal<A, B> =
|
||||
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
||||
type Expect<T extends true> = T;
|
||||
|
||||
describe("run contracts", () => {
|
||||
it("test_a_turn_cause_is_not_assignable_where_pre_turn_failed_cause_is_required", () => {
|
||||
function requiresPreTurn(_cause: PreTurnFailedCause): void {}
|
||||
const turnCause: TurnCause = { kind: "turn_failed", error: new Error("boom") };
|
||||
|
||||
// A turn cause has a different `kind` and no `phase`, so it is not
|
||||
// assignable where a `PreTurnFailedCause` is required.
|
||||
// @ts-expect-error a TurnCause is not a PreTurnFailedCause.
|
||||
requiresPreTurn(turnCause);
|
||||
|
||||
expect(turnCause.kind).toBe("turn_failed");
|
||||
});
|
||||
|
||||
it("test_only_seal_produces_ready_run_resources_and_only_take_for_settlement_produces_consumed_run_resources", () => {
|
||||
// The assertions live in an unexecuted function so tsc checks the types
|
||||
// without a runtime call on a synthetic ledger.
|
||||
function _assertBrands(ledger: AcquiredRunResources): void {
|
||||
// `seal()` is the only source of a `ReadyRunResources`;
|
||||
// `takeForSettlement()` is the only source of a `ConsumedRunResources`.
|
||||
const ready: ReadyRunResources = ledger.seal([]);
|
||||
const consumed: ConsumedRunResources = ledger.takeForSettlement();
|
||||
|
||||
// The two brands are distinct: neither result is assignable to the other.
|
||||
// @ts-expect-error a ReadyRunResources is not a ConsumedRunResources.
|
||||
const _crossA: ConsumedRunResources = ready;
|
||||
// @ts-expect-error a ConsumedRunResources is not a ReadyRunResources.
|
||||
const _crossB: ReadyRunResources = consumed;
|
||||
|
||||
// A plain object cannot stand in for a branded result; it lacks the
|
||||
// private brand that only `seal()` / `takeForSettlement()` can set.
|
||||
// @ts-expect-error missing the private ready brand.
|
||||
const _fakeReady: ReadyRunResources = {
|
||||
get: () => undefined,
|
||||
scopeOf: () => undefined,
|
||||
has: () => false,
|
||||
sealed: [],
|
||||
};
|
||||
// @ts-expect-error missing the private consumed brand.
|
||||
const _fakeConsumed: ConsumedRunResources = { entries: () => [] };
|
||||
|
||||
void ready;
|
||||
void consumed;
|
||||
void _crossA;
|
||||
void _crossB;
|
||||
void _fakeReady;
|
||||
void _fakeConsumed;
|
||||
}
|
||||
|
||||
void _assertBrands;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("test_session_fingerprint_identity_has_no_company_agent_or_task_member", () => {
|
||||
// None of the outer-key parts appear as a fingerprint field.
|
||||
type ForbiddenKey = "companyId" | "agentId" | "taskKey";
|
||||
type LeakedKey = Extract<keyof SessionFingerprintIdentity, ForbiddenKey>;
|
||||
type _noLeak = Expect<Equal<LeakedKey, never>>;
|
||||
|
||||
const _ok: _noLeak = true;
|
||||
expect(_ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
// Contract types for the ACPX run coordinator refactor.
|
||||
//
|
||||
// This module declares the types only. No production code consumes them yet.
|
||||
// Each placeholder type maps to a field that `AcpxPreparedRuntime` carries in
|
||||
// `execute.ts` today. The later coordinator phases move the ownership of those
|
||||
// fields onto these contracts one seam at a time.
|
||||
//
|
||||
// Two brands (`ReadyRunResources`, `ConsumedRunResources`) mark a resource set
|
||||
// that only the resource ledger can produce. Two more brands mark a run-scoped
|
||||
// launch contribution and the finalized launch environment. A brand is a private
|
||||
// `unique symbol`, so no other module can build a branded value by a plain
|
||||
// object literal. Only the module that owns the symbol returns the value.
|
||||
|
||||
import type { AcpRuntime, AcpRuntimeHandle } from "acpx/runtime";
|
||||
import type {
|
||||
AdapterExecutionTarget,
|
||||
AdapterExecutionTargetPaperclipBridgeHandle,
|
||||
AdapterExecutionTargetProcessSessionBridgeHandle,
|
||||
PreparedAdapterExecutionTargetRuntime,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The closed set of run resources the ledger can hold. The set is fixed; a new
|
||||
* resource needs a new member here and a payload in `RunResourcePayloads`.
|
||||
*
|
||||
* `acp_runtime` is a composite of the runtime, the session handle, and the
|
||||
* child process. `runtime.close()` is the one release boundary for all three.
|
||||
*
|
||||
* These are NOT resources and never enter the ledger: `sessionKey`, the
|
||||
* environment lease, the realized execution target, the cleanup registrations,
|
||||
* the reuse-store entries, the three OTel spans, the turn timer, the child
|
||||
* stderr flush state, and the idle timers.
|
||||
*/
|
||||
export type ResourceId =
|
||||
| "acp_runtime"
|
||||
| "staged_runtime"
|
||||
| "control_bridge"
|
||||
| "agent_bridge"
|
||||
| "managed_home"
|
||||
| "staging_lease";
|
||||
|
||||
/**
|
||||
* The outcome the ledger records for a resource when it leaves the ledger.
|
||||
* `finalized` means the run released the resource here. `transferred` means the
|
||||
* run handed ownership to settlement.
|
||||
*/
|
||||
export type ResourceDisposition = "finalized" | "transferred";
|
||||
|
||||
/**
|
||||
* The lifetime a cleanup belongs to.
|
||||
*
|
||||
* A `startup_rollback` cleanup runs only when startup fails, to undo a partial
|
||||
* bring-up. A `per_run` cleanup runs at settlement, at the end of the run. A
|
||||
* successful `seal()` promotes each promotable `startup_rollback` entry to
|
||||
* `per_run`, because the resource now lives through the whole run.
|
||||
*/
|
||||
export type CleanupScope = "startup_rollback" | "per_run";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The `acp_runtime` composite. `runtime.close()` releases the runtime, the
|
||||
* session handle, and the child process together. Maps to the runtime, the
|
||||
* session handle, and the spawned child that `execute.ts` builds in
|
||||
* `buildRuntime`.
|
||||
*/
|
||||
export interface AcpRuntimeResource {
|
||||
readonly runtime: AcpRuntime;
|
||||
readonly sessionHandle: AcpRuntimeHandle;
|
||||
/** The pid of the spawned agent child, or null before the first spawn. */
|
||||
readonly childProcessPid: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged in-sandbox runtime and its one-time host-side cleanup. Maps to
|
||||
* `AcpxPreparedRuntime.stagedRuntime` and `remoteStagingDispose`.
|
||||
*/
|
||||
export interface StagedRuntimeResource {
|
||||
readonly stagedRuntime: PreparedAdapterExecutionTargetRuntime;
|
||||
/** Remove the host-side staged temp. Fired only when the stage is dropped. */
|
||||
readonly disposeStaged: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-adapter managed-home seam. Maps to
|
||||
* `AcpxPreparedRuntime.remoteManagedHomeTeardown` and `remoteStagingEnvDelta`.
|
||||
*/
|
||||
export interface ManagedHomeResource {
|
||||
/** Per-run copy-back hook. Runs the auth copy-back on every exit path. */
|
||||
readonly teardown: () => Promise<void>;
|
||||
/** The env keys the seam mutated on this run. */
|
||||
readonly envDelta: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-session staging lease. Maps to
|
||||
* `AcpxPreparedRuntime.sessionStagingLeaseRelease`.
|
||||
*/
|
||||
export interface StagingLeaseResource {
|
||||
/** Release the staging lease so a later overlapping run can re-stage. */
|
||||
readonly release: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps each resource id to its payload. The `control_bridge` is the Paperclip
|
||||
* control-plane bridge; the `agent_bridge` is the agent process-session bridge.
|
||||
*/
|
||||
export interface RunResourcePayloads {
|
||||
readonly acp_runtime: AcpRuntimeResource;
|
||||
readonly staged_runtime: StagedRuntimeResource;
|
||||
readonly control_bridge: AdapterExecutionTargetPaperclipBridgeHandle;
|
||||
readonly agent_bridge: AdapterExecutionTargetProcessSessionBridgeHandle;
|
||||
readonly managed_home: ManagedHomeResource;
|
||||
readonly staging_lease: StagingLeaseResource;
|
||||
}
|
||||
|
||||
/** One resource the caller registers into the ledger. */
|
||||
export interface RunResourceRegistration<Id extends ResourceId = ResourceId> {
|
||||
readonly id: Id;
|
||||
readonly payload: RunResourcePayloads[Id];
|
||||
readonly scope: CleanupScope;
|
||||
/**
|
||||
* Marks a `startup_rollback` entry that `seal()` promotes to `per_run`.
|
||||
* Default true. Set false to pin the entry as rollback-only. Ignored for a
|
||||
* `per_run` entry.
|
||||
*/
|
||||
readonly promotable?: boolean;
|
||||
/**
|
||||
* The ledger calls this once when it finalizes or transfers the resource. It
|
||||
* records the disposition. It runs no release itself.
|
||||
*/
|
||||
readonly onDisposition?: (disposition: ResourceDisposition) => void;
|
||||
}
|
||||
|
||||
/** One resource the settlement claim carries out of the ledger. */
|
||||
export interface SettledResourceEntry<Id extends ResourceId = ResourceId> {
|
||||
readonly id: Id;
|
||||
readonly payload: RunResourcePayloads[Id];
|
||||
readonly scope: CleanupScope;
|
||||
readonly disposition: ResourceDisposition;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ledger surface and its two branded results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
declare const READY_RUN_RESOURCES: unique symbol;
|
||||
declare const CONSUMED_RUN_RESOURCES: unique symbol;
|
||||
|
||||
/**
|
||||
* The sealed, validated resource set. Only `AcquiredRunResources.seal()`
|
||||
* produces this brand. The turn reads its resources through this handle.
|
||||
*/
|
||||
export interface ReadyRunResources {
|
||||
readonly [READY_RUN_RESOURCES]: true;
|
||||
/** The payload for a resource id, or undefined when the slot is empty. */
|
||||
get<Id extends ResourceId>(id: Id): RunResourcePayloads[Id] | undefined;
|
||||
/** The scope of a resource id after promotion, or undefined when empty. */
|
||||
scopeOf(id: ResourceId): CleanupScope | undefined;
|
||||
/** True when the slot holds a resource. */
|
||||
has(id: ResourceId): boolean;
|
||||
/** The required ids that `seal()` validated. */
|
||||
readonly sealed: readonly ResourceId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-time settlement claim. Only `AcquiredRunResources.takeForSettlement()`
|
||||
* produces this brand. Settlement releases the resources this claim carries.
|
||||
*/
|
||||
export interface ConsumedRunResources {
|
||||
readonly [CONSUMED_RUN_RESOURCES]: true;
|
||||
/** The resources, each with its final scope and disposition. */
|
||||
entries(): readonly SettledResourceEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The write surface the startup path holds while it acquires resources.
|
||||
* `register` is the only writer. `seal` and `takeForSettlement` end the write
|
||||
* phase. The state machine is `open -> sealed -> consumed` or `open ->
|
||||
* consumed`.
|
||||
*/
|
||||
export interface AcquiredRunResources {
|
||||
register<Id extends ResourceId>(registration: RunResourceRegistration<Id>): void;
|
||||
seal(required: readonly ResourceId[]): ReadyRunResources;
|
||||
takeForSettlement(): ConsumedRunResources;
|
||||
}
|
||||
|
||||
/** The error the ledger throws on an out-of-order or duplicate operation. */
|
||||
export class LedgerStateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "LedgerStateError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup, turn, and settlement outcomes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The four phases a pre-turn failure can occur in. A pre-turn failure happens
|
||||
* after the ledger acquires resources but before the turn runs.
|
||||
*/
|
||||
export interface PreTurnFailedCause {
|
||||
readonly kind: "pre_turn_failed";
|
||||
readonly phase:
|
||||
| "runtime_create"
|
||||
| "handshake"
|
||||
| "session_handle_missing"
|
||||
| "session_configuration";
|
||||
readonly error: Error;
|
||||
}
|
||||
|
||||
/** The turn ran and failed with an error. */
|
||||
export interface TurnFailedCause {
|
||||
readonly kind: "turn_failed";
|
||||
readonly error: Error;
|
||||
}
|
||||
|
||||
/** The caller cancelled the turn. */
|
||||
export interface TurnCancelledCause {
|
||||
readonly kind: "turn_cancelled";
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** The turn hit its wall-clock timeout. */
|
||||
export interface TurnTimedOutCause {
|
||||
readonly kind: "turn_timed_out";
|
||||
readonly timeoutSec: number;
|
||||
}
|
||||
|
||||
/** A cause that the turn itself produced. Disjoint from `PreTurnFailedCause`. */
|
||||
export type TurnCause = TurnFailedCause | TurnCancelledCause | TurnTimedOutCause;
|
||||
|
||||
/** Any cause that leads a run into settlement. */
|
||||
export type SettlementCause = PreTurnFailedCause | TurnCause;
|
||||
|
||||
/**
|
||||
* Startup produced a ready runtime. The turn can run against these resources.
|
||||
*/
|
||||
export interface StartupReady {
|
||||
readonly kind: "ready";
|
||||
readonly resources: ReadyRunResources;
|
||||
readonly context: AcpRunContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup failed after it acquired resources, so the run must settle them. A
|
||||
* build failure and a partial-bridge failure do NOT reach here — they throw,
|
||||
* and the coordinator owns that rollback (Phase 20).
|
||||
*/
|
||||
export interface StartupSettle {
|
||||
readonly kind: "settle";
|
||||
readonly cause: PreTurnFailedCause;
|
||||
readonly resources: ConsumedRunResources;
|
||||
}
|
||||
|
||||
export type StartupResult = StartupReady | StartupSettle;
|
||||
|
||||
/**
|
||||
* The turn finished and released everything in place. It carries no resources,
|
||||
* so settlement has nothing to do.
|
||||
*/
|
||||
export interface FinalizedTurn {
|
||||
readonly kind: "finalized";
|
||||
}
|
||||
|
||||
/** The turn failed. Settlement releases the carried resources. */
|
||||
export interface FailedTurn {
|
||||
readonly kind: "failed";
|
||||
readonly cause: TurnFailedCause;
|
||||
readonly resources: ConsumedRunResources;
|
||||
}
|
||||
|
||||
/** The caller cancelled the turn. Settlement releases the carried resources. */
|
||||
export interface CancelledTurn {
|
||||
readonly kind: "cancelled";
|
||||
readonly cause: TurnCancelledCause;
|
||||
readonly resources: ConsumedRunResources;
|
||||
}
|
||||
|
||||
/** The turn timed out. Settlement releases the carried resources. */
|
||||
export interface TimedOutTurn {
|
||||
readonly kind: "timed_out";
|
||||
readonly cause: TurnTimedOutCause;
|
||||
readonly resources: ConsumedRunResources;
|
||||
}
|
||||
|
||||
/** The four ways a turn completes. Only `FinalizedTurn` is resourceless. */
|
||||
export type TurnCompletion = FinalizedTurn | FailedTurn | CancelledTurn | TimedOutTurn;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run site
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The host site runs on the host. The sandbox site runs in a remote runner. */
|
||||
export type RunSiteKind = "host" | "sandbox";
|
||||
|
||||
/**
|
||||
* The synchronous plan a site produces before the fingerprint build. `plan()`
|
||||
* must be synchronous, because `sessionCwd` is a fingerprint input.
|
||||
*/
|
||||
export interface SitePlan {
|
||||
/**
|
||||
* The cwd the session binds to and the fingerprint reads. `placeWorkspace` is
|
||||
* not its source; the site derives it before it places the workspace.
|
||||
*/
|
||||
readonly sessionCwd: string;
|
||||
/**
|
||||
* The host-only spawn cwd for the relay proxy on the remote process-session
|
||||
* lane. Undefined on every other lane. Maps to `AcpxPreparedRuntime.
|
||||
* hostSpawnCwd`.
|
||||
*/
|
||||
readonly spawnCwd: string | undefined;
|
||||
/** True only for a sandbox target, where the run emits real OTel spans. */
|
||||
readonly tracingEnabled: boolean;
|
||||
}
|
||||
|
||||
/** One referenced project whose staging into the sandbox failed. */
|
||||
export interface ReferencedProjectStagingFailure {
|
||||
readonly projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of placing the workspace. Carries the referenced-project staging
|
||||
* failures the run reports back on its result.
|
||||
*/
|
||||
export interface PlacedWorkspace {
|
||||
readonly referencedProjectStagingFailures: readonly ReferencedProjectStagingFailure[];
|
||||
}
|
||||
|
||||
/** The transport handles the site started for the run. */
|
||||
export interface RunSiteTransport {
|
||||
readonly controlBridge: AdapterExecutionTargetPaperclipBridgeHandle | null;
|
||||
readonly agentBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null;
|
||||
}
|
||||
|
||||
/** What the host site's store can save: the live runtime and session handle. */
|
||||
export interface HostReuseCandidate {
|
||||
readonly runtime: AcpRuntime;
|
||||
readonly sessionHandle: AcpRuntimeHandle;
|
||||
}
|
||||
|
||||
/** What the sandbox site's store can save: the staged files. */
|
||||
export interface SandboxReuseCandidate {
|
||||
readonly stagedRuntime: PreparedAdapterExecutionTargetRuntime;
|
||||
}
|
||||
|
||||
export type RunSiteReuseCandidate = HostReuseCandidate | SandboxReuseCandidate;
|
||||
|
||||
/** Construction options for a `SessionReuseStore`. */
|
||||
export interface SessionReuseStoreOptions {
|
||||
/**
|
||||
* The optional per-entry idle timeout, in milliseconds. When set, `evictIdle`
|
||||
* drops an entry that stayed idle longer than this bound.
|
||||
*/
|
||||
readonly idleMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-site store that saves a reuse candidate across runs. The store is
|
||||
* generic in the saved type, so a generic `save` cannot know what a site saves.
|
||||
* `RunSite.reuseCandidate()` names the concrete type per site.
|
||||
*/
|
||||
export interface SessionReuseStore<T> {
|
||||
borrow(sessionKey: string): T | undefined;
|
||||
save(sessionKey: string, entry: T): void;
|
||||
/**
|
||||
* Identity-guarded removal that fires the idempotent release. It removes the
|
||||
* key synchronously, so a later reuse never reads a discarded entry. It
|
||||
* returns a promise the caller can await when the release must finish before
|
||||
* the run releases a downstream resource (e.g. the staging lease).
|
||||
*/
|
||||
discard(sessionKey: string): void | Promise<void>;
|
||||
/**
|
||||
* Run-start idle sweep. It removes each stale key synchronously, so
|
||||
* `buildRuntime` never reuses an expired entry. It returns a promise the
|
||||
* caller can await so the sweep completes before the run reads the cache.
|
||||
*/
|
||||
evictIdle(now: number): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A run site. The host site and the sandbox site differ in what they place,
|
||||
* what transport they start, and what their store saves.
|
||||
*/
|
||||
export interface RunSite<TReuse extends RunSiteReuseCandidate = RunSiteReuseCandidate> {
|
||||
readonly kind: RunSiteKind;
|
||||
/** Synchronous. Runs before the fingerprint build. */
|
||||
plan(context: AcpRunContext): SitePlan;
|
||||
placeWorkspace(context: AcpRunContext): Promise<PlacedWorkspace>;
|
||||
startTransport(context: AcpRunContext): Promise<RunSiteTransport>;
|
||||
reuse(): SessionReuseStore<TReuse>;
|
||||
/** Names the concrete candidate this site's store can save. */
|
||||
reuseCandidate(resources: ReadyRunResources): TReuse;
|
||||
syncBack(context: AcpRunContext): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity, launch environment, and run context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AcpxRunMode = "persistent" | "oneshot";
|
||||
export type AcpxPermissionMode = "approve-all" | "approve-reads" | "deny-all";
|
||||
export type AcpxNonInteractivePermissions = "deny" | "fail";
|
||||
|
||||
/** One MCP server, in the shape the fingerprint reads. */
|
||||
export interface McpServerIdentity {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
readonly connectionId: string;
|
||||
}
|
||||
|
||||
/** The Paperclip Claude settings the fingerprint reads. */
|
||||
export interface PaperclipClaudeSettingsIdentity {
|
||||
readonly allow: readonly string[];
|
||||
readonly additionalDirectories: readonly string[];
|
||||
readonly defaultMode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 17 fields the session fingerprint hashes. Company, agent, and task
|
||||
* identifiers are NOT here; they scope the outer session key only (see
|
||||
* `SessionKeyIdentity`). A change to any field here invalidates a warm handle
|
||||
* or a resumable session and forces a fresh launch.
|
||||
*/
|
||||
export interface SessionFingerprintIdentity {
|
||||
readonly acpxAgent: string;
|
||||
readonly agentCommand: string;
|
||||
readonly cwd: string;
|
||||
readonly mode: AcpxRunMode;
|
||||
readonly permissionMode: AcpxPermissionMode;
|
||||
readonly nonInteractivePermissions: AcpxNonInteractivePermissions;
|
||||
readonly requestedModel: string;
|
||||
readonly requestedThinkingEffort: string;
|
||||
readonly fastMode: boolean;
|
||||
readonly remoteExecutionIdentity: Record<string, unknown> | null;
|
||||
readonly additionalSourcesIdentity: Record<string, unknown>;
|
||||
readonly skillsIdentity: Record<string, unknown>;
|
||||
readonly skillPromptInstructions: string;
|
||||
readonly paperclipClaudeSettings: PaperclipClaudeSettingsIdentity | null;
|
||||
readonly mcpServers: readonly McpServerIdentity[];
|
||||
readonly secretManifestHash: string;
|
||||
readonly adapterEnvHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The company, agent, and task parts of the session key. The key form is
|
||||
* `paperclip:companyId:agentId:taskKey:fingerprint`. These parts stay out of
|
||||
* the fingerprint hash.
|
||||
*/
|
||||
export interface SessionKeyIdentity {
|
||||
readonly companyId: string;
|
||||
readonly agentId: string;
|
||||
readonly taskKey: string;
|
||||
}
|
||||
|
||||
declare const RUN_SCOPED_CONTRIBUTION: unique symbol;
|
||||
declare const LAUNCH_ENVIRONMENT: unique symbol;
|
||||
|
||||
/**
|
||||
* A launch-environment contribution that lives for one run only. The run API
|
||||
* key and every bridge token are run-scoped. The brand makes this type
|
||||
* non-transferable: no reuse payload type can hold it (Amendment B).
|
||||
*/
|
||||
export interface RunScopedContribution {
|
||||
readonly scope: "run";
|
||||
readonly env: Record<string, string>;
|
||||
readonly [RUN_SCOPED_CONTRIBUTION]: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A launch-environment contribution that lives for the whole session. The Codex
|
||||
* auth copy-back material is session-scoped.
|
||||
*/
|
||||
export interface SessionScopedContribution {
|
||||
readonly scope: "session";
|
||||
readonly env: Record<string, string>;
|
||||
}
|
||||
|
||||
export type LaunchEnvironmentContribution = RunScopedContribution | SessionScopedContribution;
|
||||
|
||||
/**
|
||||
* The finalized launch environment. Only `finalizeLaunchEnvironment()`
|
||||
* (Phase 17) produces this brand. No reuse payload type can hold it.
|
||||
*/
|
||||
export interface LaunchEnvironment {
|
||||
readonly [LAUNCH_ENVIRONMENT]: true;
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The context the coordinator threads through the run. The site reads the
|
||||
* execution target for its effective capabilities.
|
||||
*/
|
||||
export interface AcpRunContext {
|
||||
readonly executionTarget: AdapterExecutionTarget;
|
||||
readonly fingerprintIdentity: SessionFingerprintIdentity;
|
||||
readonly keyIdentity: SessionKeyIdentity;
|
||||
readonly fingerprint: string;
|
||||
readonly sessionKey: string;
|
||||
readonly launchEnvironment: LaunchEnvironment;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runAttempt, type RunPlan, type SettlementReason } from "./run-coordinator.js";
|
||||
import { createRunResourceLedger } from "./run-resource-ledger.js";
|
||||
import type {
|
||||
AcquiredRunResources,
|
||||
PreTurnFailedCause,
|
||||
ReadyRunResources,
|
||||
StartupReady,
|
||||
StartupResult,
|
||||
TurnCompletion,
|
||||
} from "./run-contracts.js";
|
||||
|
||||
// The coordinator owns the run routing table. These tests drive it with fakes
|
||||
// for the four steps (startup, turn, settle, reproduce) and assert the order and
|
||||
// the routing, never the engine's real run state.
|
||||
|
||||
const readyResources = {} as ReadyRunResources;
|
||||
|
||||
function readyStartup(): StartupReady {
|
||||
return { kind: "ready", resources: readyResources, context: {} as StartupReady["context"] };
|
||||
}
|
||||
|
||||
function preTurnCause(): PreTurnFailedCause {
|
||||
return { kind: "pre_turn_failed", phase: "handshake", error: new Error("handshake boom") };
|
||||
}
|
||||
|
||||
function finalizedTurn(): TurnCompletion {
|
||||
return { kind: "finalized" };
|
||||
}
|
||||
|
||||
// A minimal plan whose steps push their names into `order`, so a test reads the
|
||||
// exact sequence the coordinator ran.
|
||||
function makePlan(
|
||||
order: string[],
|
||||
overrides: Partial<RunPlan<string>> & { ledger?: AcquiredRunResources } = {},
|
||||
): RunPlan<string> {
|
||||
const ledger = overrides.ledger ?? createRunResourceLedger();
|
||||
return {
|
||||
ledger,
|
||||
startup: overrides.startup ?? (async (): Promise<StartupResult> => {
|
||||
order.push("startup");
|
||||
return readyStartup();
|
||||
}),
|
||||
rollbackStartup: overrides.rollbackStartup ?? ((consumed) => {
|
||||
order.push(`rollback:${consumed.entries().length}`);
|
||||
}),
|
||||
recordDisposition: overrides.recordDisposition ?? ((report) => {
|
||||
order.push(`disposition:${report.records.map((r) => `${r.id}=${r.disposition}`).join(",")}`);
|
||||
}),
|
||||
runTurn: overrides.runTurn ?? (async (): Promise<TurnCompletion> => {
|
||||
order.push("turn");
|
||||
return finalizedTurn();
|
||||
}),
|
||||
settle: overrides.settle ?? (async (reason: SettlementReason) => {
|
||||
order.push(`settle:${reason.kind}`);
|
||||
}),
|
||||
reproduceResult: overrides.reproduceResult ?? (async (outcome) => {
|
||||
order.push(`reproduce:${outcome.kind}`);
|
||||
return "result";
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ACPX run coordinator", () => {
|
||||
it("test_startup_throw_replays_startup_rollback_entries_and_rethrows", async () => {
|
||||
const order: string[] = [];
|
||||
// Fill a partial ledger with two startup_rollback entries that record their
|
||||
// release order when the rollback replays them.
|
||||
const ledger = createRunResourceLedger();
|
||||
const released: string[] = [];
|
||||
ledger.register({
|
||||
id: "staged_runtime",
|
||||
payload: {
|
||||
stagedRuntime: {} as never,
|
||||
disposeStaged: async () => {
|
||||
released.push("staged_runtime");
|
||||
},
|
||||
},
|
||||
scope: "startup_rollback",
|
||||
});
|
||||
ledger.register({
|
||||
id: "control_bridge",
|
||||
payload: { stop: async () => { released.push("control_bridge"); } } as never,
|
||||
scope: "startup_rollback",
|
||||
});
|
||||
|
||||
const boom = new Error("build boom");
|
||||
const plan = makePlan(order, {
|
||||
ledger,
|
||||
startup: async (): Promise<StartupResult> => {
|
||||
order.push("startup");
|
||||
throw boom;
|
||||
},
|
||||
// Replay every claimed entry through its own release, newest first.
|
||||
rollbackStartup: async (consumed) => {
|
||||
for (const entry of [...consumed.entries()].reverse()) {
|
||||
if (entry.id === "staged_runtime") {
|
||||
await (entry.payload as { disposeStaged: () => Promise<void> }).disposeStaged();
|
||||
} else if (entry.id === "control_bridge") {
|
||||
await (entry.payload as { stop: () => Promise<void> }).stop();
|
||||
}
|
||||
}
|
||||
order.push("rollback");
|
||||
},
|
||||
});
|
||||
|
||||
// The coordinator rethrows the original startup error.
|
||||
await expect(runAttempt(plan)).rejects.toBe(boom);
|
||||
// It replayed the startup_rollback entries through the partial ledger...
|
||||
expect(released).toEqual(["control_bridge", "staged_runtime"]);
|
||||
// ...recorded a disposition report that marks each claimed entry finalized...
|
||||
expect(order).toEqual([
|
||||
"startup",
|
||||
"rollback",
|
||||
"disposition:staged_runtime=finalized,control_bridge=finalized",
|
||||
]);
|
||||
// ...and never ran the turn or the settlement.
|
||||
expect(order).not.toContain("turn");
|
||||
expect(order.some((step) => step.startsWith("settle"))).toBe(false);
|
||||
});
|
||||
|
||||
it("test_settle_path_returns_error_result_after_settlement", async () => {
|
||||
const order: string[] = [];
|
||||
const cause = preTurnCause();
|
||||
const plan = makePlan(order, {
|
||||
startup: async (): Promise<StartupResult> => {
|
||||
order.push("startup");
|
||||
return { kind: "settle", cause, resources: createRunResourceLedger().takeForSettlement() };
|
||||
},
|
||||
settle: async (reason) => {
|
||||
expect(reason.kind).toBe("pre_turn");
|
||||
order.push("settle");
|
||||
},
|
||||
reproduceResult: async (outcome) => {
|
||||
expect(outcome.kind).toBe("pre_turn");
|
||||
order.push("reproduce");
|
||||
return "error-result";
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runAttempt(plan);
|
||||
expect(result).toBe("error-result");
|
||||
// The settle path never runs the turn; it settles first, then reproduces.
|
||||
expect(order).toEqual(["startup", "settle", "reproduce"]);
|
||||
});
|
||||
|
||||
it("test_ready_path_runs_turn_then_settles_then_reproduces_result", async () => {
|
||||
const order: string[] = [];
|
||||
const plan = makePlan(order);
|
||||
|
||||
const result = await runAttempt(plan);
|
||||
expect(result).toBe("result");
|
||||
// The ready path runs the turn, settles, then reproduces — in that order.
|
||||
expect(order).toEqual(["startup", "turn", "settle:turn", "reproduce:turn"]);
|
||||
});
|
||||
|
||||
it("test_coordinator_has_no_catch_around_the_turn", async () => {
|
||||
// `runTurn` is contracted never to reject. This test proves the coordinator
|
||||
// holds no catch around the turn: an (illegal) turn rejection propagates out
|
||||
// of `runAttempt` unchanged, and neither settlement nor result reproduction
|
||||
// runs. A catch around the turn would swallow the rejection and route it into
|
||||
// settlement, so this rejection-propagation is the structural proof.
|
||||
const order: string[] = [];
|
||||
const boom = new Error("turn must never reject");
|
||||
const settleCalls: number[] = [];
|
||||
const reproduceCalls: number[] = [];
|
||||
const plan = makePlan(order, {
|
||||
runTurn: async (): Promise<TurnCompletion> => {
|
||||
throw boom;
|
||||
},
|
||||
settle: async () => {
|
||||
settleCalls.push(1);
|
||||
},
|
||||
reproduceResult: async () => {
|
||||
reproduceCalls.push(1);
|
||||
return "unreachable";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAttempt(plan)).rejects.toBe(boom);
|
||||
expect(settleCalls).toHaveLength(0);
|
||||
expect(reproduceCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// The run coordinator.
|
||||
//
|
||||
// One `run()` coordinator owns the attempt. It sequences four steps: startup,
|
||||
// turn, settlement, and result reproduction. It holds the routing table as
|
||||
// data:
|
||||
//
|
||||
// * A build failure or a partial-bridge failure throws from startup. The
|
||||
// coordinator's one catch replays the `startup_rollback` entries through the
|
||||
// partial ledger, records the disposition report, and rethrows.
|
||||
// * A runtime-create, handshake, missing-handle, or configuration failure
|
||||
// produces a `settle` result. The coordinator settles the resources, then
|
||||
// reproduces the error result.
|
||||
// * Every other startup outcome produces a `ready` result. The coordinator
|
||||
// runs the turn, settles the resources, then reproduces the turn result.
|
||||
//
|
||||
// The coordinator has no catch around the turn: the turn never rejects and
|
||||
// returns a typed `TurnCompletion` (Phase 21). The external result is reproduced
|
||||
// AFTER settlement, so a caller never observes a result before the run releases
|
||||
// its resources.
|
||||
|
||||
import type {
|
||||
AcquiredRunResources,
|
||||
ConsumedRunResources,
|
||||
PreTurnFailedCause,
|
||||
ResourceDisposition,
|
||||
ResourceId,
|
||||
StartupReady,
|
||||
StartupResult,
|
||||
TurnCompletion,
|
||||
} from "./run-contracts.js";
|
||||
|
||||
/** One resource's final disposition, as the coordinator or settlement records it. */
|
||||
export interface ResourceDispositionRecord {
|
||||
readonly id: ResourceId;
|
||||
readonly disposition: ResourceDisposition;
|
||||
}
|
||||
|
||||
/** The per-resource disposition report the coordinator records at each end. */
|
||||
export interface SettlementDispositionReport {
|
||||
readonly records: readonly ResourceDispositionRecord[];
|
||||
}
|
||||
|
||||
/** The cause the coordinator hands to settlement. A clean turn carries no cause. */
|
||||
export type SettlementReason =
|
||||
| { readonly kind: "pre_turn"; readonly cause: PreTurnFailedCause }
|
||||
| { readonly kind: "turn"; readonly completion: TurnCompletion };
|
||||
|
||||
/** The outcome the coordinator hands to result reproduction. */
|
||||
export type ReproducibleOutcome = SettlementReason;
|
||||
|
||||
/**
|
||||
* The four steps the coordinator sequences, plus the run ledger. The engine
|
||||
* supplies each step; the coordinator owns only the order and the startup
|
||||
* rollback. This keeps the coordinator free of the engine's private run state.
|
||||
*/
|
||||
export interface RunPlan<TResult> {
|
||||
/** The one run ledger for the attempt. The coordinator creates it (see `runAttempt`). */
|
||||
readonly ledger: AcquiredRunResources;
|
||||
/**
|
||||
* Run the startup sequence. It returns a `ready` or a `settle` result, or it
|
||||
* throws on a build failure or a partial-bridge failure.
|
||||
*/
|
||||
startup(): Promise<StartupResult>;
|
||||
/**
|
||||
* Replay the `startup_rollback` entries the partial ledger carries. The
|
||||
* coordinator calls it only on a startup throw, with the claimed entries.
|
||||
*/
|
||||
rollbackStartup(consumed: ConsumedRunResources): Promise<void> | void;
|
||||
/** Record the per-resource disposition report at the end of the run. */
|
||||
recordDisposition(report: SettlementDispositionReport): void;
|
||||
/** Run the turn against the ready resources. It never rejects. */
|
||||
runTurn(ready: StartupReady): Promise<TurnCompletion>;
|
||||
/**
|
||||
* Settle the run's resources for the given reason. The one cleanup owner. A
|
||||
* synchronous return adds no awaited boundary, so a step that already settled
|
||||
* inline keeps the caller's timing unchanged.
|
||||
*/
|
||||
settle(reason: SettlementReason): void | Promise<void>;
|
||||
/** Reproduce the external result after settlement. */
|
||||
reproduceResult(outcome: ReproducibleOutcome): TResult | Promise<TResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the attempt through the routing table. The coordinator is the one owner of
|
||||
* the startup-to-settlement order.
|
||||
*/
|
||||
export async function runAttempt<TResult>(plan: RunPlan<TResult>): Promise<TResult> {
|
||||
let startupResult: StartupResult;
|
||||
try {
|
||||
startupResult = await plan.startup();
|
||||
} catch (startupError) {
|
||||
// The coordinator's only catch wraps startup. A build failure and a
|
||||
// partial-bridge failure reach here. Claim the partial ledger, replay its
|
||||
// `startup_rollback` entries, record the disposition report (each entry the
|
||||
// rollback released is `finalized`), and rethrow the original error.
|
||||
const consumed = plan.ledger.takeForSettlement();
|
||||
await plan.rollbackStartup(consumed);
|
||||
plan.recordDisposition(finalizedReport(consumed));
|
||||
throw startupError;
|
||||
}
|
||||
|
||||
if (startupResult.kind === "settle") {
|
||||
// A pre-turn failure after the ledger acquired resources. Settle the
|
||||
// resources, then reproduce the error result AFTER settlement.
|
||||
const reason: SettlementReason = { kind: "pre_turn", cause: startupResult.cause };
|
||||
const settled = plan.settle(reason);
|
||||
if (settled) await settled;
|
||||
return plan.reproduceResult(reason);
|
||||
}
|
||||
|
||||
// The ready path: run the turn, settle, then reproduce the result. There is no
|
||||
// catch around the turn — `runTurn` never rejects.
|
||||
const completion = await plan.runTurn(startupResult);
|
||||
const reason: SettlementReason = { kind: "turn", completion };
|
||||
const settled = plan.settle(reason);
|
||||
if (settled) await settled;
|
||||
return plan.reproduceResult(reason);
|
||||
}
|
||||
|
||||
/** Build a disposition report that marks every claimed entry `finalized`. */
|
||||
function finalizedReport(consumed: ConsumedRunResources): SettlementDispositionReport {
|
||||
return {
|
||||
records: consumed.entries().map((entry) => ({ id: entry.id, disposition: "finalized" as const })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
startAdapterExecutionTargetProcessSessionBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import { runChildProcess } from "../server-utils.js";
|
||||
|
||||
// The composed fault matrix.
|
||||
//
|
||||
// Each case drives the whole ACP run and asserts the run's final settlement
|
||||
// disposition report. The report is the authority on which resources the run
|
||||
// acquired and how each one left the run: `finalized` (the run released it) or
|
||||
// `transferred` (the run saved it for reuse). For each case the file asserts:
|
||||
//
|
||||
// 1. the report keys equal the acquired subset of the six resource ids;
|
||||
// 2. each resource carries exactly one disposition;
|
||||
// 3. the external result equals the composed baseline for that exit path.
|
||||
//
|
||||
// The six resource ids are: acp_runtime, staged_runtime, control_bridge,
|
||||
// agent_bridge, managed_home, staging_lease. The host lane acquires only
|
||||
// acp_runtime; the sandbox lane acquires the staging and transport resources too.
|
||||
|
||||
// Wrap the staging seam and both sandbox bridges so a test can stub them without
|
||||
// changing behavior for the other tests.
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
|
||||
return {
|
||||
...actual,
|
||||
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
|
||||
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
createAcpxEngineExecutor,
|
||||
type AcpxEngineExecutorOptions,
|
||||
} from "./execute.js";
|
||||
import { settleAcpRun, type SettlementSteps } from "./settlement-sequence.js";
|
||||
import { createRunResourceLedger } from "./run-resource-ledger.js";
|
||||
import { LedgerStateError, type ResourceId } from "./run-contracts.js";
|
||||
import type { SettlementDispositionReport } from "./run-coordinator.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function makeTempRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-fault-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) =>
|
||||
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function createLocalSandboxRunner() {
|
||||
let counter = 0;
|
||||
return {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}) => {
|
||||
counter += 1;
|
||||
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
||||
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
env: input.env ?? {},
|
||||
stdin: input.stdin,
|
||||
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
||||
graceSec: 5,
|
||||
onLog: input.onLog ?? (async () => {}),
|
||||
onSpawn: input.onSpawn
|
||||
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const okHandle = {
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
};
|
||||
|
||||
function completedTurn() {
|
||||
return {
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function throwingTurn() {
|
||||
return {
|
||||
events: (async function* () {
|
||||
throw new Error("turn upstream boom");
|
||||
})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function cancelledTurn() {
|
||||
return {
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "cancelled", stopReason: "cancelled" }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// A runtime whose completed turn keeps the session handshake and turn simple.
|
||||
function completedRuntime() {
|
||||
return {
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
setConfigOption: async () => {},
|
||||
close: async () => {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
// A context whose only prompt-read field throws when read, so the prompt build
|
||||
// fails after the session handshake and before the turn starts.
|
||||
function throwingHandoffContext(): Record<string, unknown> {
|
||||
const context: Record<string, unknown> = {};
|
||||
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error("prompt build boom");
|
||||
},
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
// Stub both sandbox bridges, so a case reaches the post-build window with live
|
||||
// handles without running the real bridge transport. `stopRejects` makes the
|
||||
// bridge stop reject during settlement.
|
||||
function stubBridges(options: { stopRejects?: boolean } = {}) {
|
||||
const stop = options.stopRejects
|
||||
? vi.fn(async () => {
|
||||
throw new Error("bridge stop boom");
|
||||
})
|
||||
: vi.fn(async () => {});
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => {
|
||||
return { env: {}, stop } as never;
|
||||
});
|
||||
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => {
|
||||
return { agentCommand: null, stop } as never;
|
||||
});
|
||||
}
|
||||
|
||||
async function setupRemoteSandbox() {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const localCwd = path.join(root, "worktree");
|
||||
const remoteCwd = path.join(root, "remote-workspace");
|
||||
await fs.mkdir(localCwd, { recursive: true });
|
||||
await fs.mkdir(remoteCwd, { recursive: true });
|
||||
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
||||
const executionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "fake-plugin",
|
||||
remoteCwd,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
||||
}
|
||||
|
||||
function remoteArgs(
|
||||
stateDir: string,
|
||||
localCwd: string,
|
||||
executionTarget: unknown,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
context: {},
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Seed the managed home through the staging seam and return a per-run copy-back
|
||||
// so the run registers the managed_home resource. `teardownRejects` makes the
|
||||
// copy-back reject during the settlement sync-back.
|
||||
function managedHomeSeed(options: { teardownRejects?: boolean } = {}): AcpxEngineExecutorOptions["prepareRemoteManagedHome"] {
|
||||
return async (input) => {
|
||||
const stagedRuntime = await input.stage([]);
|
||||
return {
|
||||
stagedRuntime,
|
||||
teardown: options.teardownRejects
|
||||
? async () => {
|
||||
throw new Error("copy-back boom");
|
||||
}
|
||||
: async () => {},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Capture the final settlement disposition report the run records.
|
||||
function captureDisposition() {
|
||||
const reports: SettlementDispositionReport[] = [];
|
||||
return {
|
||||
onSettlementDisposition: (report: SettlementDispositionReport) => {
|
||||
reports.push(report);
|
||||
},
|
||||
last(): SettlementDispositionReport | null {
|
||||
return reports.length > 0 ? reports[reports.length - 1]! : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The shared per-case assertion. It proves the report keys equal the acquired
|
||||
// subset, each resource carries exactly one disposition, and the transferred id
|
||||
// (if any) is the only `transferred` entry.
|
||||
function assertDispositionReport(
|
||||
report: SettlementDispositionReport | null,
|
||||
expected: { acquired: ResourceId[]; transferred?: ResourceId | null },
|
||||
) {
|
||||
expect(report, "the run must record a disposition report").not.toBeNull();
|
||||
const records = report!.records;
|
||||
const ids = records.map((record) => record.id);
|
||||
// Each resource carries exactly one disposition (no duplicate id).
|
||||
expect(ids.length, "no resource is reported twice").toBe(new Set(ids).size);
|
||||
// The report keys equal the acquired subset of the six resource ids.
|
||||
expect(new Set(ids)).toEqual(new Set(expected.acquired));
|
||||
const transferred = expected.transferred ?? null;
|
||||
for (const record of records) {
|
||||
expect(["finalized", "transferred"]).toContain(record.disposition);
|
||||
if (record.id === transferred) {
|
||||
expect(record.disposition, `${record.id} must be transferred`).toBe("transferred");
|
||||
} else {
|
||||
expect(record.disposition, `${record.id} must be finalized`).toBe("finalized");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sandbox-lane resource set before the runtime is created (staging and
|
||||
// transport), and after (adds the runtime). Without a managed-home seed the
|
||||
// managed_home resource is absent.
|
||||
const SANDBOX_PRE_RUNTIME: ResourceId[] = ["staged_runtime", "staging_lease", "control_bridge", "agent_bridge"];
|
||||
const SANDBOX_WITH_RUNTIME: ResourceId[] = [...SANDBOX_PRE_RUNTIME, "acp_runtime"];
|
||||
const SANDBOX_WITH_MANAGED_HOME: ResourceId[] = [
|
||||
"staged_runtime",
|
||||
"managed_home",
|
||||
"staging_lease",
|
||||
"control_bridge",
|
||||
"agent_bridge",
|
||||
"acp_runtime",
|
||||
];
|
||||
|
||||
describe("composed ACPX run fault matrix", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Case 1 — a build failure throws before the ledger acquires any resource.
|
||||
it("case_01_build_failure_throws_and_acquires_nothing", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
vi.mocked(prepareAdapterExecutionTargetRuntime).mockImplementationOnce(async () => {
|
||||
throw new Error("staging boom");
|
||||
});
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => completedRuntime(),
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
await expect(
|
||||
execute({ runId: "fault-build", ...remoteArgs(stateDir, localCwd, executionTarget) } as never),
|
||||
).rejects.toThrow("staging boom");
|
||||
|
||||
assertDispositionReport(capture.last(), { acquired: [] });
|
||||
});
|
||||
|
||||
// Case 2 — a partial bridge failure throws; the staged entry and the lease are
|
||||
// finalized (the rollback removed the borrowed staged entry).
|
||||
it("case_02_partial_bridge_failure_finalizes_staged_and_lease", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const capture = captureDisposition();
|
||||
const stop = vi.fn(async () => {});
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
|
||||
throw new Error("paperclip bridge boom");
|
||||
});
|
||||
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
||||
async () => ({ agentCommand: null, stop }) as never,
|
||||
);
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => completedRuntime(),
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
await expect(
|
||||
execute({ runId: "fault-bridge", ...remoteArgs(stateDir, localCwd, executionTarget) } as never),
|
||||
).rejects.toThrow("paperclip bridge boom");
|
||||
|
||||
assertDispositionReport(capture.last(), { acquired: ["staged_runtime", "staging_lease"] });
|
||||
});
|
||||
|
||||
// Case 3 — a runtime-create failure settles the staging and transport resources.
|
||||
// The runtime never enters the ledger.
|
||||
it("case_03_runtime_create_failure_finalizes_staging_and_transport", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => {
|
||||
throw new Error("createRuntime boom");
|
||||
},
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-create",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("create_runtime");
|
||||
assertDispositionReport(capture.last(), { acquired: SANDBOX_PRE_RUNTIME });
|
||||
});
|
||||
|
||||
// Case 4 — a handshake failure closes the runtime; every resource finalizes.
|
||||
it("case_04_handshake_failure_closes_runtime", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => {
|
||||
throw new Error("ensureSession boom");
|
||||
},
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-handshake",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("ensure_session");
|
||||
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
|
||||
});
|
||||
|
||||
// Case 5 — a missing session handle closes the runtime; the result carries a
|
||||
// null clearSession and no errorMeta.
|
||||
it("case_05_missing_handle_closes_runtime_null_error", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => undefined,
|
||||
startTurn: () => completedTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-missing-handle",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("ensure_session");
|
||||
expect(result.errorCode).toBe("acpx_runtime_error");
|
||||
expect(result.clearSession).toBeUndefined();
|
||||
expect(result.errorMeta).toBeUndefined();
|
||||
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
|
||||
});
|
||||
|
||||
// Case 6 — a configuration failure closes the runtime.
|
||||
it("case_06_configuration_failure_closes_runtime", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => completedTurn(),
|
||||
setConfigOption: async () => {
|
||||
throw new Error("setConfigOption boom");
|
||||
},
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-configure",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget, {
|
||||
config: {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
model: "custom-model-x",
|
||||
stateDir,
|
||||
cwd: localCwd,
|
||||
},
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("configure_session");
|
||||
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
|
||||
});
|
||||
|
||||
// Case 7 — a prompt-build failure fails the turn in the prepare phase.
|
||||
it("case_07_prompt_build_failure_finalizes_every_resource", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => completedRuntime(),
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-prepare",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget, { context: throwingHandoffContext() }),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("prepare_turn");
|
||||
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
|
||||
});
|
||||
|
||||
// Case 8 — a resume retry runs ensureSession twice but registers the runtime
|
||||
// once, so the acquired set has no doubled resource.
|
||||
it("case_08_resume_retry_does_not_double_resources", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const cwd = path.join(root, "worktree");
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
const config = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd };
|
||||
|
||||
// A first clean host run mints the session params the second run resumes from.
|
||||
const firstExecute = createAcpxEngineExecutor({ createRuntime: () => completedRuntime() });
|
||||
const first = await firstExecute({
|
||||
runId: "fault-resume-a",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
expect(first.exitCode).toBe(0);
|
||||
|
||||
// The second run resumes: the resume ensureSession fails, the fresh retry
|
||||
// succeeds. The runtime is created once, so acp_runtime registers once.
|
||||
const capture = captureDisposition();
|
||||
let created = 0;
|
||||
const secondExecute = createAcpxEngineExecutor({
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
createRuntime: () => {
|
||||
created += 1;
|
||||
return {
|
||||
ensureSession: async (session: Record<string, unknown>) => {
|
||||
if (session.resumeSessionId) throw new Error("resume session not found");
|
||||
return okHandle;
|
||||
},
|
||||
startTurn: () => completedTurn(),
|
||||
setConfigOption: async () => {},
|
||||
close: async () => {},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
const second = await secondExecute({
|
||||
runId: "fault-resume-b",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: { sessionParams: (first as { sessionParams?: unknown }).sessionParams },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(second.exitCode).toBe(0);
|
||||
// The runtime was created exactly once across the resume and the fresh retry.
|
||||
expect(created).toBe(1);
|
||||
// The host lane acquires only the runtime, and it appears once.
|
||||
assertDispositionReport(capture.last(), { acquired: ["acp_runtime"] });
|
||||
});
|
||||
|
||||
// Case 9 — a turn exception finalizes every resource (the staged runtime is
|
||||
// discarded, not transferred).
|
||||
it("case_09_turn_exception_discards_staged_runtime", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => throwingTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-turn",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("turn");
|
||||
expect(result.errorCode).toBe("acpx_turn_failed");
|
||||
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
|
||||
});
|
||||
|
||||
// Case 10 — a turn timeout finalizes every resource.
|
||||
it("case_10_turn_timeout_finalizes_every_resource", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
let releaseTurn: (() => void) | null = null;
|
||||
const turnCancelled = new Promise<void>((resolve) => {
|
||||
releaseTurn = resolve;
|
||||
});
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {
|
||||
await turnCancelled;
|
||||
})(),
|
||||
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
|
||||
cancel: async () => {
|
||||
releaseTurn?.();
|
||||
},
|
||||
}),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-timeout",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget, {
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd, timeoutSec: 1 },
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.errorCode).toBe("acpx_timeout");
|
||||
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
|
||||
}, 15_000);
|
||||
|
||||
// Case 11 — a cancelled turn finalizes every resource.
|
||||
it("case_11_turn_cancellation_finalizes_every_resource", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => cancelledTurn(),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-cancel",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.status).toBe("cancelled");
|
||||
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
|
||||
});
|
||||
|
||||
// Case 12 — a clean success saves the staged runtime (transferred); every other
|
||||
// resource finalizes. A managed-home seed exercises the full six-resource set.
|
||||
it("case_12_clean_success_transfers_staged_runtime", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => completedRuntime(),
|
||||
prepareRemoteManagedHome: managedHomeSeed(),
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-success",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.resultJson?.status).toBe("completed");
|
||||
assertDispositionReport(capture.last(), {
|
||||
acquired: SANDBOX_WITH_MANAGED_HOME,
|
||||
transferred: "staged_runtime",
|
||||
});
|
||||
});
|
||||
|
||||
// Case 13 — a bridge stop that rejects during settlement never changes the
|
||||
// result or the report (allSettled absorbs the fault).
|
||||
it("case_13_bridge_stop_reject_does_not_change_result", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges({ stopRejects: true });
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => completedRuntime(),
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-bridge-stop",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.resultJson?.status).toBe("completed");
|
||||
assertDispositionReport(capture.last(), {
|
||||
acquired: SANDBOX_WITH_RUNTIME,
|
||||
transferred: "staged_runtime",
|
||||
});
|
||||
});
|
||||
|
||||
// Case 14 — a sync-back copy-back that rejects during settlement never changes
|
||||
// the result or the report.
|
||||
it("case_14_sync_back_failure_does_not_change_result", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
stubBridges();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => completedRuntime(),
|
||||
prepareRemoteManagedHome: managedHomeSeed({ teardownRejects: true }),
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-sync-back",
|
||||
...remoteArgs(stateDir, localCwd, executionTarget),
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.resultJson?.status).toBe("completed");
|
||||
assertDispositionReport(capture.last(), {
|
||||
acquired: SANDBOX_WITH_MANAGED_HOME,
|
||||
transferred: "staged_runtime",
|
||||
});
|
||||
});
|
||||
|
||||
// Case 15 — a concurrent double settlement is a structural error: the second
|
||||
// claim of the ledger throws a LedgerStateError.
|
||||
it("case_15_concurrent_double_settlement_throws_ledger_state_error", async () => {
|
||||
const ledger = createRunResourceLedger();
|
||||
ledger.register({
|
||||
id: "acp_runtime",
|
||||
payload: { runtime: {}, sessionHandle: {}, childProcessPid: null } as never,
|
||||
scope: "per_run",
|
||||
});
|
||||
const noopSteps: SettlementSteps = {
|
||||
reuseCandidate: () => null,
|
||||
endSession: () => {},
|
||||
settleReuse: () => {},
|
||||
stopTransport: () => {},
|
||||
syncBack: () => {},
|
||||
releaseStagingLease: () => {},
|
||||
recordError: () => {},
|
||||
};
|
||||
|
||||
// The first settlement claims the ledger and produces the report.
|
||||
const report = await settleAcpRun(ledger, null, noopSteps);
|
||||
assertDispositionReport(report, { acquired: ["acp_runtime"] });
|
||||
|
||||
// A second settlement of the same ledger is a structural error.
|
||||
await expect(settleAcpRun(ledger, null, noopSteps)).rejects.toBeInstanceOf(LedgerStateError);
|
||||
});
|
||||
|
||||
// Case 16 — the host-lane credential gate. Constructed gate-passed: the runtime
|
||||
// transfers and never closes. Live host path (gate failed): the runtime closes
|
||||
// and never transfers.
|
||||
it("case_16_host_credential_gate_transfer_versus_close", async () => {
|
||||
// Constructed gate-passed condition: a host candidate with no live run-scoped
|
||||
// credential. The reuse decision saves, so the runtime transfers and endSession
|
||||
// never closes it.
|
||||
const ledger = createRunResourceLedger();
|
||||
ledger.register({
|
||||
id: "acp_runtime",
|
||||
payload: { runtime: {}, sessionHandle: {}, childProcessPid: null } as never,
|
||||
scope: "per_run",
|
||||
});
|
||||
let closed = false;
|
||||
const gatePassedSteps: SettlementSteps = {
|
||||
reuseCandidate: () => ({ kind: "host", causePermitsSave: true, liveRunScopedCredentials: [] }),
|
||||
endSession: (_slots, decision) => {
|
||||
if (decision.kind !== "save") closed = true;
|
||||
},
|
||||
settleReuse: () => {},
|
||||
stopTransport: () => {},
|
||||
syncBack: () => {},
|
||||
releaseStagingLease: () => {},
|
||||
recordError: () => {},
|
||||
};
|
||||
const gatePassedReport = await settleAcpRun(ledger, null, gatePassedSteps);
|
||||
expect(closed).toBe(false);
|
||||
assertDispositionReport(gatePassedReport, { acquired: ["acp_runtime"], transferred: "acp_runtime" });
|
||||
|
||||
// Live host path: the engine populates the run-scoped credential, so the gate
|
||||
// fails, the runtime closes, and it never transfers.
|
||||
const root = await makeTempRoot();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
createRuntime: () => completedRuntime(),
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
const result = await execute({
|
||||
runId: "fault-host-gate",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir: path.join(root, "state"),
|
||||
mode: "persistent",
|
||||
warmHandleIdleMs: 60_000,
|
||||
},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// The live host path closes and never transfers the runtime.
|
||||
assertDispositionReport(capture.last(), { acquired: ["acp_runtime"], transferred: null });
|
||||
});
|
||||
|
||||
// Case 17 — a host-lane turn failure finalizes (closes) the runtime.
|
||||
it("case_17_host_turn_failure_finalizes_runtime", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const capture = captureDisposition();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
startTurn: () => throwingTurn(),
|
||||
setConfigOption: async () => {},
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
onSettlementDisposition: capture.onSettlementDisposition,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "fault-host-turn-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir: path.join(root, "state"),
|
||||
mode: "persistent",
|
||||
warmHandleIdleMs: 60_000,
|
||||
},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("turn");
|
||||
assertDispositionReport(capture.last(), { acquired: ["acp_runtime"], transferred: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("composed ACPX run per-phase telemetry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("emits a run.phase.timing event for each lifecycle phase a clean host run reaches", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles: new Map(),
|
||||
createRuntime: () => completedRuntime(),
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "phase-telemetry",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
||||
events.push(event);
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const phases = events
|
||||
.filter((event) => event.eventType === "run.phase.timing")
|
||||
.map((event) => String(event.payload?.phase));
|
||||
// A clean host run runs the runtime, session, prepare, turn, and settlement
|
||||
// phases. Each phase emits a phase-timing event.
|
||||
for (const phase of ["create_runtime", "ensure_session", "prepare_turn", "turn", "end_session"]) {
|
||||
expect(phases, `missing phase telemetry for ${phase}`).toContain(phase);
|
||||
}
|
||||
// Every phase-timing event carries exactly the three closed fields.
|
||||
for (const event of events.filter((entry) => entry.eventType === "run.phase.timing")) {
|
||||
expect(Object.keys(event.payload ?? {}).sort()).toEqual(["durationMs", "outcome", "phase"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRunResourceLedger, createScopedCleanupRegistry } from "./run-resource-ledger.js";
|
||||
import { LedgerStateError } from "./run-contracts.js";
|
||||
import type { RunResourcePayloads } from "./run-contracts.js";
|
||||
|
||||
// A typed placeholder payload for a resource whose value the test never reads.
|
||||
function stub<T>(): T {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
describe("run resource ledger", () => {
|
||||
it("test_register_throws_on_filled_slot", () => {
|
||||
const ledger = createRunResourceLedger();
|
||||
ledger.register({ id: "staging_lease", scope: "per_run", payload: { release: () => {} } });
|
||||
|
||||
expect(() =>
|
||||
ledger.register({ id: "staging_lease", scope: "per_run", payload: { release: () => {} } }),
|
||||
).toThrow(LedgerStateError);
|
||||
});
|
||||
|
||||
it("test_register_throws_after_seal_and_after_take", () => {
|
||||
const sealed = createRunResourceLedger();
|
||||
sealed.register({ id: "staging_lease", scope: "per_run", payload: { release: () => {} } });
|
||||
sealed.seal(["staging_lease"]);
|
||||
expect(() =>
|
||||
sealed.register({
|
||||
id: "managed_home",
|
||||
scope: "per_run",
|
||||
payload: stub<RunResourcePayloads["managed_home"]>(),
|
||||
}),
|
||||
).toThrow(LedgerStateError);
|
||||
|
||||
const taken = createRunResourceLedger();
|
||||
taken.register({ id: "staging_lease", scope: "per_run", payload: { release: () => {} } });
|
||||
taken.takeForSettlement();
|
||||
expect(() =>
|
||||
taken.register({
|
||||
id: "managed_home",
|
||||
scope: "per_run",
|
||||
payload: stub<RunResourcePayloads["managed_home"]>(),
|
||||
}),
|
||||
).toThrow(LedgerStateError);
|
||||
});
|
||||
|
||||
it("test_seal_validates_site_declared_required_slots", () => {
|
||||
const ledger = createRunResourceLedger();
|
||||
ledger.register({
|
||||
id: "acp_runtime",
|
||||
scope: "per_run",
|
||||
payload: stub<RunResourcePayloads["acp_runtime"]>(),
|
||||
});
|
||||
|
||||
// The site declares control_bridge required, but it is not registered, so
|
||||
// seal throws and leaves the ledger open.
|
||||
expect(() => ledger.seal(["acp_runtime", "control_bridge"])).toThrow(LedgerStateError);
|
||||
|
||||
// The failed seal left the ledger open, so the missing slot can be filled.
|
||||
ledger.register({
|
||||
id: "control_bridge",
|
||||
scope: "per_run",
|
||||
payload: stub<RunResourcePayloads["control_bridge"]>(),
|
||||
});
|
||||
const ready = ledger.seal(["acp_runtime", "control_bridge"]);
|
||||
|
||||
expect(ready.has("acp_runtime")).toBe(true);
|
||||
expect(ready.has("control_bridge")).toBe(true);
|
||||
expect([...ready.sealed]).toEqual(["acp_runtime", "control_bridge"]);
|
||||
});
|
||||
|
||||
it("test_seal_promotes_startup_rollback_entries_to_per_run", () => {
|
||||
const ledger = createRunResourceLedger();
|
||||
// Promotable by default.
|
||||
ledger.register({
|
||||
id: "staged_runtime",
|
||||
scope: "startup_rollback",
|
||||
payload: stub<RunResourcePayloads["staged_runtime"]>(),
|
||||
});
|
||||
// Pinned as rollback-only.
|
||||
ledger.register({
|
||||
id: "control_bridge",
|
||||
scope: "startup_rollback",
|
||||
promotable: false,
|
||||
payload: stub<RunResourcePayloads["control_bridge"]>(),
|
||||
});
|
||||
ledger.register({
|
||||
id: "acp_runtime",
|
||||
scope: "per_run",
|
||||
payload: stub<RunResourcePayloads["acp_runtime"]>(),
|
||||
});
|
||||
|
||||
const ready = ledger.seal(["staged_runtime", "control_bridge", "acp_runtime"]);
|
||||
|
||||
expect(ready.scopeOf("staged_runtime")).toBe("per_run");
|
||||
expect(ready.scopeOf("control_bridge")).toBe("startup_rollback");
|
||||
expect(ready.scopeOf("acp_runtime")).toBe("per_run");
|
||||
});
|
||||
|
||||
it("test_take_for_settlement_is_synchronous_one_time_claim", () => {
|
||||
const ledger = createRunResourceLedger();
|
||||
ledger.register({
|
||||
id: "acp_runtime",
|
||||
scope: "per_run",
|
||||
payload: stub<RunResourcePayloads["acp_runtime"]>(),
|
||||
});
|
||||
|
||||
const consumed = ledger.takeForSettlement();
|
||||
|
||||
// Synchronous: the claim comes back directly, not as a promise.
|
||||
expect(consumed).not.toBeInstanceOf(Promise);
|
||||
expect(consumed.entries().map((entry) => entry.id)).toContain("acp_runtime");
|
||||
|
||||
// One-time: a second claim throws.
|
||||
expect(() => ledger.takeForSettlement()).toThrow(LedgerStateError);
|
||||
});
|
||||
|
||||
it("test_second_take_throws_ledger_state_error_before_any_side_effect", () => {
|
||||
const ledger = createRunResourceLedger();
|
||||
const onDisposition = vi.fn();
|
||||
ledger.register({
|
||||
id: "staging_lease",
|
||||
scope: "per_run",
|
||||
payload: { release: () => {} },
|
||||
onDisposition,
|
||||
});
|
||||
|
||||
const first = ledger.takeForSettlement();
|
||||
expect(first.entries().map((entry) => entry.id)).toEqual(["staging_lease"]);
|
||||
expect(onDisposition).toHaveBeenCalledTimes(1);
|
||||
expect(onDisposition).toHaveBeenCalledWith("transferred");
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
ledger.takeForSettlement();
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(LedgerStateError);
|
||||
// The second claim threw before it marked a disposition a second time.
|
||||
expect(onDisposition).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scoped cleanup registry runs and promotes cleanups by scope", async () => {
|
||||
const registry = createScopedCleanupRegistry();
|
||||
const order: string[] = [];
|
||||
registry.add("startup_rollback", () => {
|
||||
order.push("rollback-a");
|
||||
});
|
||||
registry.add("startup_rollback", async () => {
|
||||
order.push("rollback-b");
|
||||
});
|
||||
registry.add("per_run", () => {
|
||||
order.push("per-run");
|
||||
});
|
||||
expect(registry.size("startup_rollback")).toBe(2);
|
||||
|
||||
registry.promote("startup_rollback", "per_run");
|
||||
expect(registry.size("startup_rollback")).toBe(0);
|
||||
expect(registry.size("per_run")).toBe(3);
|
||||
|
||||
await registry.run("per_run");
|
||||
|
||||
// Reverse registration order (last in, first out).
|
||||
expect(order).toEqual(["rollback-b", "rollback-a", "per-run"]);
|
||||
expect(registry.size("per_run")).toBe(0);
|
||||
});
|
||||
|
||||
it("scoped cleanup registry runs every cleanup even when one fails", async () => {
|
||||
const registry = createScopedCleanupRegistry();
|
||||
const ran: string[] = [];
|
||||
registry.add("per_run", () => {
|
||||
ran.push("first");
|
||||
});
|
||||
registry.add("per_run", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
registry.add("per_run", () => {
|
||||
ran.push("third");
|
||||
});
|
||||
|
||||
await expect(registry.run("per_run")).rejects.toBeInstanceOf(AggregateError);
|
||||
// The failing cleanup did not skip the others.
|
||||
expect(ran).toEqual(["third", "first"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// The run resource ledger and the scoped cleanup registry.
|
||||
//
|
||||
// The ledger is the single owner of the run resources between acquisition and
|
||||
// settlement. `register` is the only writer. `seal` ends acquisition and
|
||||
// validates the required set. `takeForSettlement` makes the one-time claim that
|
||||
// hands the resources to settlement. The state machine is `open -> sealed ->
|
||||
// consumed` or `open -> consumed`.
|
||||
//
|
||||
// The scoped cleanup registry holds cleanup thunks by lifetime. The coordinator
|
||||
// runs the `startup_rollback` scope when startup fails and the `per_run` scope
|
||||
// at settlement. A successful seal promotes the rollback cleanups to per_run.
|
||||
|
||||
import { LedgerStateError } from "./run-contracts.js";
|
||||
import type {
|
||||
AcquiredRunResources,
|
||||
CleanupScope,
|
||||
ConsumedRunResources,
|
||||
ReadyRunResources,
|
||||
ResourceDisposition,
|
||||
ResourceId,
|
||||
RunResourcePayloads,
|
||||
RunResourceRegistration,
|
||||
SettledResourceEntry,
|
||||
} from "./run-contracts.js";
|
||||
|
||||
type LedgerState = "open" | "sealed" | "consumed";
|
||||
|
||||
interface LedgerSlot {
|
||||
readonly id: ResourceId;
|
||||
readonly payload: RunResourcePayloads[ResourceId];
|
||||
scope: CleanupScope;
|
||||
readonly promotable: boolean;
|
||||
readonly onDisposition?: (disposition: ResourceDisposition) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a run resource ledger. The ledger starts `open`. `register` fills at
|
||||
* most one slot per resource id. `seal` validates the required set and promotes
|
||||
* promotable `startup_rollback` entries to `per_run`. `takeForSettlement` makes
|
||||
* the one-time settlement claim.
|
||||
*/
|
||||
export function createRunResourceLedger(): AcquiredRunResources {
|
||||
const slots = new Map<ResourceId, LedgerSlot>();
|
||||
let state: LedgerState = "open";
|
||||
let sealedRequired: readonly ResourceId[] = [];
|
||||
|
||||
function readyView(): ReadyRunResources {
|
||||
const view = {
|
||||
get(id: ResourceId): RunResourcePayloads[ResourceId] | undefined {
|
||||
return slots.get(id)?.payload;
|
||||
},
|
||||
scopeOf(id: ResourceId): CleanupScope | undefined {
|
||||
return slots.get(id)?.scope;
|
||||
},
|
||||
has(id: ResourceId): boolean {
|
||||
return slots.has(id);
|
||||
},
|
||||
sealed: sealedRequired,
|
||||
};
|
||||
return view as unknown as ReadyRunResources;
|
||||
}
|
||||
|
||||
return {
|
||||
register(registration: RunResourceRegistration): void {
|
||||
if (state !== "open") {
|
||||
throw new LedgerStateError(
|
||||
state === "sealed"
|
||||
? "cannot register a resource after the ledger is sealed"
|
||||
: "cannot register a resource after the ledger is taken for settlement",
|
||||
);
|
||||
}
|
||||
if (slots.has(registration.id)) {
|
||||
throw new LedgerStateError(`resource slot "${registration.id}" is already filled`);
|
||||
}
|
||||
slots.set(registration.id, {
|
||||
id: registration.id,
|
||||
payload: registration.payload,
|
||||
scope: registration.scope,
|
||||
promotable: registration.promotable ?? true,
|
||||
onDisposition: registration.onDisposition,
|
||||
});
|
||||
},
|
||||
|
||||
seal(required: readonly ResourceId[]): ReadyRunResources {
|
||||
if (state !== "open") {
|
||||
throw new LedgerStateError(
|
||||
state === "sealed"
|
||||
? "the ledger is already sealed"
|
||||
: "cannot seal the ledger after it is taken for settlement",
|
||||
);
|
||||
}
|
||||
// Validate the required set first. A failed seal must leave the ledger
|
||||
// open and unchanged, so the caller can register the missing slot and
|
||||
// seal again.
|
||||
const missing = required.filter((id) => !slots.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new LedgerStateError(`seal is missing required resource(s): ${missing.join(", ")}`);
|
||||
}
|
||||
// Promote every promotable startup_rollback entry to per_run in one pass.
|
||||
// The resource now lives through the whole run, so settlement owns it.
|
||||
for (const slot of slots.values()) {
|
||||
if (slot.scope === "startup_rollback" && slot.promotable) {
|
||||
slot.scope = "per_run";
|
||||
}
|
||||
}
|
||||
state = "sealed";
|
||||
sealedRequired = [...required];
|
||||
return readyView();
|
||||
},
|
||||
|
||||
takeForSettlement(): ConsumedRunResources {
|
||||
// The one-time claim. Check the state first, so a second claim throws
|
||||
// before it can mark a disposition or hand the resources out again.
|
||||
if (state === "consumed") {
|
||||
throw new LedgerStateError("run resources are already taken for settlement");
|
||||
}
|
||||
state = "consumed";
|
||||
const entries: SettledResourceEntry[] = [];
|
||||
for (const slot of slots.values()) {
|
||||
slot.onDisposition?.("transferred");
|
||||
entries.push({
|
||||
id: slot.id,
|
||||
payload: slot.payload,
|
||||
scope: slot.scope,
|
||||
disposition: "transferred",
|
||||
});
|
||||
}
|
||||
const consumed = { entries: () => entries };
|
||||
return consumed as unknown as ConsumedRunResources;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry of cleanup thunks, grouped by lifetime scope. The coordinator adds
|
||||
* a cleanup under a scope, promotes a scope's cleanups to another scope, and
|
||||
* runs a scope's cleanups.
|
||||
*/
|
||||
export interface ScopedCleanupRegistry {
|
||||
/** Add a cleanup under a scope. */
|
||||
add(scope: CleanupScope, cleanup: () => void | Promise<void>): void;
|
||||
/** Move every cleanup from one scope to another, in registration order. */
|
||||
promote(from: CleanupScope, to: CleanupScope): void;
|
||||
/** The count of cleanups in a scope. */
|
||||
size(scope: CleanupScope): number;
|
||||
/**
|
||||
* Run and clear a scope's cleanups in reverse registration order. Run every
|
||||
* cleanup even when one fails. Throw an `AggregateError` at the end when one
|
||||
* or more failed, so no error is lost and no cleanup is skipped.
|
||||
*/
|
||||
run(scope: CleanupScope): Promise<void>;
|
||||
}
|
||||
|
||||
/** Create an empty scoped cleanup registry. */
|
||||
export function createScopedCleanupRegistry(): ScopedCleanupRegistry {
|
||||
const byScope = new Map<CleanupScope, Array<() => void | Promise<void>>>();
|
||||
|
||||
function bucket(scope: CleanupScope): Array<() => void | Promise<void>> {
|
||||
let list = byScope.get(scope);
|
||||
if (!list) {
|
||||
list = [];
|
||||
byScope.set(scope, list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
return {
|
||||
add(scope: CleanupScope, cleanup: () => void | Promise<void>): void {
|
||||
bucket(scope).push(cleanup);
|
||||
},
|
||||
|
||||
promote(from: CleanupScope, to: CleanupScope): void {
|
||||
if (from === to) return;
|
||||
const source = byScope.get(from);
|
||||
if (!source || source.length === 0) return;
|
||||
const target = bucket(to);
|
||||
target.push(...source);
|
||||
byScope.set(from, []);
|
||||
},
|
||||
|
||||
size(scope: CleanupScope): number {
|
||||
return byScope.get(scope)?.length ?? 0;
|
||||
},
|
||||
|
||||
async run(scope: CleanupScope): Promise<void> {
|
||||
const list = byScope.get(scope) ?? [];
|
||||
byScope.set(scope, []);
|
||||
const errors: unknown[] = [];
|
||||
for (let index = list.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
await list[index]();
|
||||
} catch (err) {
|
||||
errors.push(err);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, `cleanup scope "${scope}" had ${errors.length} failure(s)`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Tests for the host run site (Phase 18).
|
||||
//
|
||||
// The host site is the null path with a warm-runtime store. These tests pin its
|
||||
// warm-hit and warm-save behavior against the baseline warm sets, and confirm
|
||||
// the null-path operations: host cwd plan, no staging failures, no transport,
|
||||
// and no sync-back.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHostRunSite, type RuntimeCacheEntry } from "./run-site-host.js";
|
||||
import type { AcpRunContext, ReadyRunResources } from "./run-contracts.js";
|
||||
|
||||
function makeEntry(id: string, lastUsedAt: number): RuntimeCacheEntry {
|
||||
return {
|
||||
runtime: { id, close: async () => {} } as never,
|
||||
handle: { sessionKey: id } as never,
|
||||
childStderrState: { logPath: null, pendingLiveLine: "" },
|
||||
processIdentitySink: { current: undefined, latest: null },
|
||||
fingerprint: `fp-${id}`,
|
||||
lastUsedAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe("host run site", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("test_host_site_warm_hit_and_warm_save_match_baseline_sets", async () => {
|
||||
const warmHandles = new Map<string, RuntimeCacheEntry>();
|
||||
const closed: RuntimeCacheEntry[] = [];
|
||||
const site = createHostRunSite({
|
||||
warmHandles,
|
||||
now: () => Date.now(),
|
||||
idleMs: 60_000,
|
||||
closeWarmEntry: async (entry) => {
|
||||
closed.push(entry);
|
||||
},
|
||||
});
|
||||
const store = site.reuse();
|
||||
|
||||
// Warm save: the entry lands in the caller's map as the sole warm entry, the
|
||||
// same set the baseline warm-save path produces (`warmHandles.size` is 1).
|
||||
const entry = makeEntry("paperclip:c:a:t:fp", Date.now());
|
||||
store.save(entry.handle.sessionKey, entry);
|
||||
expect(new Set(warmHandles.keys())).toEqual(new Set(["paperclip:c:a:t:fp"]));
|
||||
expect(warmHandles.get("paperclip:c:a:t:fp")).toBe(entry);
|
||||
// The save armed a per-entry idle timer.
|
||||
expect(entry.cleanupTimer).toBeDefined();
|
||||
|
||||
// Warm hit: a borrow returns the same entry and leaves it in the map, so an
|
||||
// overlapping run of the same session still reads it. The borrow cleared the
|
||||
// idle timer, so the reused runtime does not expire while it is in use.
|
||||
expect(store.borrow("paperclip:c:a:t:fp")).toBe(entry);
|
||||
expect(warmHandles.size).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(closed).toEqual([]);
|
||||
expect(warmHandles.get("paperclip:c:a:t:fp")).toBe(entry);
|
||||
|
||||
// A discard drops the entry and closes its runtime once, matching the
|
||||
// baseline drop set (empty map, one close).
|
||||
await store.discard("paperclip:c:a:t:fp");
|
||||
expect(warmHandles.size).toBe(0);
|
||||
expect(closed).toEqual([entry]);
|
||||
});
|
||||
|
||||
it("test_host_site_plan_and_null_path_operations", async () => {
|
||||
const site = createHostRunSite({
|
||||
warmHandles: new Map(),
|
||||
now: () => 0,
|
||||
idleMs: 0,
|
||||
closeWarmEntry: async () => {},
|
||||
});
|
||||
|
||||
const context = {
|
||||
fingerprintIdentity: { cwd: "/work/dir" },
|
||||
} as unknown as AcpRunContext;
|
||||
|
||||
// The plan binds the session to the host cwd, names no relay-proxy spawn cwd,
|
||||
// and disables sandbox tracing.
|
||||
expect(site.plan(context)).toEqual({
|
||||
sessionCwd: "/work/dir",
|
||||
spawnCwd: undefined,
|
||||
tracingEnabled: false,
|
||||
});
|
||||
|
||||
// The host runs in place, so it places nothing, starts no transport, and
|
||||
// syncs nothing back.
|
||||
expect(await site.placeWorkspace(context)).toEqual({ referencedProjectStagingFailures: [] });
|
||||
expect(await site.startTransport(context)).toEqual({ controlBridge: null, agentBridge: null });
|
||||
await expect(site.syncBack(context)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("test_host_site_reuse_candidate_names_runtime_and_session_handle", () => {
|
||||
const site = createHostRunSite({
|
||||
warmHandles: new Map(),
|
||||
now: () => 0,
|
||||
idleMs: 0,
|
||||
closeWarmEntry: async () => {},
|
||||
});
|
||||
|
||||
const runtime = { id: "rt" };
|
||||
const sessionHandle = { id: "handle" };
|
||||
const resources = {
|
||||
get: (id: string) =>
|
||||
id === "acp_runtime" ? { runtime, sessionHandle, childProcessPid: 42 } : undefined,
|
||||
} as unknown as ReadyRunResources;
|
||||
|
||||
expect(site.reuseCandidate(resources)).toEqual({ runtime, sessionHandle });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// The host run site.
|
||||
//
|
||||
// The host site is the literal null path: it runs the agent on the host, with
|
||||
// no sandbox, no staging, and no bridges. Its store keeps a live ACP runtime and
|
||||
// session handle warm across runs of the same session, so a compatible resume
|
||||
// reuses the running agent instead of a fresh spawn. This is today's warm-handle
|
||||
// cache, factored behind the generic reuse store.
|
||||
//
|
||||
// The site owns the warm store only. The engine still drives the run body; the
|
||||
// site's `plan`, `placeWorkspace`, `startTransport`, and `syncBack` are the null
|
||||
// operations the host lane always did (host cwd, no staging failures, no
|
||||
// transport, no sync-back). A later phase routes the whole lane through the site.
|
||||
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import type { AcpRuntime, AcpRuntimeHandle } from "acpx/runtime";
|
||||
import type {
|
||||
AcpRunContext,
|
||||
HostReuseCandidate,
|
||||
PlacedWorkspace,
|
||||
ReadyRunResources,
|
||||
RunSiteTransport,
|
||||
SessionReuseStore,
|
||||
SitePlan,
|
||||
} from "./run-contracts.js";
|
||||
import { createSessionReuseStore } from "./session-reuse-store.js";
|
||||
|
||||
/** The agent child's identity, as the runtime reports it on each spawn. */
|
||||
export type AcpxAgentProcessIdentity = { pid: number; startedAt: string };
|
||||
|
||||
/**
|
||||
* The mutable spawn-callback target and the last spawn identity a warm runtime
|
||||
* carries across runs. A warm handle can outlive the run that created it, so the
|
||||
* `current` callback stays mutable: a later heartbeat repoints it to its own
|
||||
* `onSpawn`, and `latest` lets that heartbeat re-announce the running agent.
|
||||
*/
|
||||
export type AcpxProcessIdentitySink = {
|
||||
current: AdapterExecutionContext["onSpawn"];
|
||||
latest: AcpxAgentProcessIdentity | null;
|
||||
};
|
||||
|
||||
/** The live-line buffer and log path a warm runtime's child stderr carries. */
|
||||
export interface ChildStderrState {
|
||||
logPath: string | null;
|
||||
pendingLiveLine: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One warm host runtime the store keeps for the next compatible resume. It is
|
||||
* the value the host warm-handle map holds. The entry carries its own last-used
|
||||
* time and its own optional idle timer, which the store reads and writes.
|
||||
*/
|
||||
export interface RuntimeCacheEntry {
|
||||
runtime: AcpRuntime;
|
||||
handle: AcpRuntimeHandle;
|
||||
childStderrState: ChildStderrState;
|
||||
processIdentitySink: AcpxProcessIdentitySink;
|
||||
fingerprint: string;
|
||||
lastUsedAt: number;
|
||||
cleanupTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/** Construction options for the host site. */
|
||||
export interface HostRunSiteOptions {
|
||||
/**
|
||||
* The warm-handle map the store operates over. The engine owns it and reuses
|
||||
* it across runs, so a saved runtime stays warm for the next resume.
|
||||
*/
|
||||
readonly warmHandles: Map<string, RuntimeCacheEntry>;
|
||||
readonly now: () => number;
|
||||
/** The warm-idle bound, in milliseconds. `0` or less disables the store. */
|
||||
readonly idleMs: number;
|
||||
/**
|
||||
* Close a warm runtime and flush its child stderr. The store fires it when the
|
||||
* per-entry timer expires, when the run-start sweep evicts an idle entry, and
|
||||
* on an explicit discard. The engine injects it, because the close reason and
|
||||
* the stderr flush live in the engine module.
|
||||
*/
|
||||
readonly closeWarmEntry: (entry: RuntimeCacheEntry) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host run site. Its store keeps warm runtimes; every other operation is the
|
||||
* null path the host lane always ran.
|
||||
*/
|
||||
export interface HostRunSite {
|
||||
readonly kind: "host";
|
||||
plan(context: AcpRunContext): SitePlan;
|
||||
placeWorkspace(context: AcpRunContext): Promise<PlacedWorkspace>;
|
||||
startTransport(context: AcpRunContext): Promise<RunSiteTransport>;
|
||||
reuse(): SessionReuseStore<RuntimeCacheEntry>;
|
||||
reuseCandidate(resources: ReadyRunResources): HostReuseCandidate;
|
||||
syncBack(context: AcpRunContext): Promise<void>;
|
||||
}
|
||||
|
||||
/** Create the host run site over the engine's warm-handle map. */
|
||||
export function createHostRunSite(options: HostRunSiteOptions): HostRunSite {
|
||||
const store = createSessionReuseStore<RuntimeCacheEntry>({
|
||||
entries: options.warmHandles,
|
||||
now: options.now,
|
||||
idleMs: options.idleMs,
|
||||
lastUsedAt: (entry) => entry.lastUsedAt,
|
||||
getTimer: (entry) => entry.cleanupTimer,
|
||||
setTimer: (entry, timer) => {
|
||||
entry.cleanupTimer = timer;
|
||||
},
|
||||
// The host lane arms a per-entry idle timer on every warm save, so an idle
|
||||
// runtime closes on its own without a later run.
|
||||
perEntryTimer: true,
|
||||
release: (entry) => options.closeWarmEntry(entry),
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "host",
|
||||
plan(context: AcpRunContext): SitePlan {
|
||||
// The host binds the session to the run cwd. There is no relay-proxy spawn
|
||||
// cwd off the host, and the host emits no sandbox trace spans.
|
||||
return {
|
||||
sessionCwd: context.fingerprintIdentity.cwd,
|
||||
spawnCwd: undefined,
|
||||
tracingEnabled: false,
|
||||
};
|
||||
},
|
||||
async placeWorkspace(): Promise<PlacedWorkspace> {
|
||||
// The host runs in place, so it stages nothing and reports no referenced-
|
||||
// project staging failure.
|
||||
return { referencedProjectStagingFailures: [] };
|
||||
},
|
||||
async startTransport(): Promise<RunSiteTransport> {
|
||||
// The host lane starts no control bridge and no agent bridge.
|
||||
return { controlBridge: null, agentBridge: null };
|
||||
},
|
||||
reuse(): SessionReuseStore<RuntimeCacheEntry> {
|
||||
return store;
|
||||
},
|
||||
reuseCandidate(resources: ReadyRunResources): HostReuseCandidate {
|
||||
const runtime = resources.get("acp_runtime");
|
||||
if (!runtime) {
|
||||
throw new Error("host run site cannot name a reuse candidate without an acp_runtime resource");
|
||||
}
|
||||
return { runtime: runtime.runtime, sessionHandle: runtime.sessionHandle };
|
||||
},
|
||||
async syncBack(): Promise<void> {
|
||||
// The host runs in place, so there is nothing to sync back.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Tests for the sandbox run site (Phase 19).
|
||||
//
|
||||
// The sandbox site owns staging, the staging lease, both bridges, the launch-
|
||||
// environment contribution, and sync-back. These tests drive the site in
|
||||
// isolation with fakes: they pin the ledger registrations, the bridge overlap and
|
||||
// the callback-environment sequencing point, the staged-files reuse (which still
|
||||
// creates the runtime), the synchronous plan, and Amendment B — no run-scoped
|
||||
// contribution or bridge token escapes into the ledger or the reuse payload.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSandboxRunSite,
|
||||
type ManagedHomeSeamResult,
|
||||
type SandboxRunSiteOptions,
|
||||
type StagedRuntimeStoreEntry,
|
||||
} from "./run-site-sandbox.js";
|
||||
import type {
|
||||
AcpRunContext,
|
||||
AcquiredRunResources,
|
||||
LaunchEnvironmentContribution,
|
||||
ReadyRunResources,
|
||||
ResourceId,
|
||||
RunResourceRegistration,
|
||||
SandboxReuseCandidate,
|
||||
StagedRuntimeResource,
|
||||
} from "./run-contracts.js";
|
||||
import type { PreparedAdapterExecutionTargetRuntime } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
function makeStagedRuntime(id: string): PreparedAdapterExecutionTargetRuntime {
|
||||
return {
|
||||
runtimeRootDir: `/remote/${id}/.paperclip-runtime/acpx`,
|
||||
additionalSourceDirs: {},
|
||||
additionalSourceFailures: [],
|
||||
} as unknown as PreparedAdapterExecutionTargetRuntime;
|
||||
}
|
||||
|
||||
function makeLedger(): { ledger: AcquiredRunResources; registered: RunResourceRegistration[] } {
|
||||
const registered: RunResourceRegistration[] = [];
|
||||
const ledger: AcquiredRunResources = {
|
||||
register(registration) {
|
||||
registered.push(registration as RunResourceRegistration);
|
||||
},
|
||||
seal() {
|
||||
throw new Error("seal is not exercised by these unit tests");
|
||||
},
|
||||
takeForSettlement() {
|
||||
throw new Error("takeForSettlement is not exercised by these unit tests");
|
||||
},
|
||||
};
|
||||
return { ledger, registered };
|
||||
}
|
||||
|
||||
function makeContext(sessionKey: string): AcpRunContext {
|
||||
return { sessionKey } as unknown as AcpRunContext;
|
||||
}
|
||||
|
||||
/** Build a site over injectable fakes. Every option has a sensible default. */
|
||||
function makeSite(overrides: Partial<SandboxRunSiteOptions> = {}) {
|
||||
const { ledger, registered } = makeLedger();
|
||||
const env: Record<string, string> = { BASE: "1" };
|
||||
const stagedRuntimes = new Map<string, StagedRuntimeStoreEntry>();
|
||||
const stagingLocks = new Map<string, Promise<unknown>>();
|
||||
const freshStaged = makeStagedRuntime("fresh");
|
||||
const bridgeCalls: string[] = [];
|
||||
const options: SandboxRunSiteOptions = {
|
||||
ledger,
|
||||
stagedRuntimes,
|
||||
stagingLocks,
|
||||
now: () => 1000,
|
||||
idleMs: 60_000,
|
||||
sessionCwd: "/remote/workspace",
|
||||
spawnCwd: "/host/workspace",
|
||||
target: { kind: "remote" } as unknown as SandboxRunSiteOptions["target"],
|
||||
env,
|
||||
isCompatibleResume: false,
|
||||
stage: async () => freshStaged,
|
||||
seedManagedHome: async (stage) => {
|
||||
const stagedRuntime = await stage([]);
|
||||
// Repoint a managed-home env var, so the site captures the delta.
|
||||
env.CODEX_HOME = "/remote/home";
|
||||
return { stagedRuntime, teardown: async () => {}, dispose: async () => {} } satisfies ManagedHomeSeamResult;
|
||||
},
|
||||
disposeFreshStagedRuntime: async () => {},
|
||||
measureStageStep: (run) => run(),
|
||||
publishStagedProjectHints: () => {},
|
||||
onReuseLog: async () => {},
|
||||
startPaperclipBridge: async () => {
|
||||
bridgeCalls.push("paperclip:start");
|
||||
return { env: { PAPERCLIP_API_KEY: "run-token" }, stop: async () => {} } as never;
|
||||
},
|
||||
startProcessSessionBridge: async ({ launchEnv }) => {
|
||||
bridgeCalls.push("process-session:start");
|
||||
await launchEnv();
|
||||
return { agentCommand: null, stop: async () => {} } as never;
|
||||
},
|
||||
measureBridgeStep: (_step, run) => run(),
|
||||
finalizeLaunchEnv: (contributions) => {
|
||||
const merged = { ...env };
|
||||
for (const contribution of contributions) Object.assign(merged, contribution.env);
|
||||
return merged;
|
||||
},
|
||||
onPaperclipBridgeLog: async () => {},
|
||||
stopBridges: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
const site = createSandboxRunSite(options);
|
||||
return { site, ledger, registered, env, stagedRuntimes, stagingLocks, freshStaged, bridgeCalls, options };
|
||||
}
|
||||
|
||||
/** A minimal `ReadyRunResources` that resolves a single staged runtime. */
|
||||
function makeReady(stagedRuntime: PreparedAdapterExecutionTargetRuntime): ReadyRunResources {
|
||||
const staged: StagedRuntimeResource = { stagedRuntime, disposeStaged: async () => {} };
|
||||
return {
|
||||
get: (id: ResourceId) => (id === "staged_runtime" ? staged : undefined),
|
||||
scopeOf: () => undefined,
|
||||
has: (id: ResourceId) => id === "staged_runtime",
|
||||
sealed: ["staged_runtime"],
|
||||
} as unknown as ReadyRunResources;
|
||||
}
|
||||
|
||||
describe("sandbox run site", () => {
|
||||
it("test_sandbox_site_registers_staging_bridges_and_lease_in_ledger", async () => {
|
||||
const { site, registered } = makeSite();
|
||||
await site.placeWorkspace(makeContext("paperclip:c:a:t:fp"));
|
||||
await site.startTransport(makeContext("paperclip:c:a:t:fp"));
|
||||
|
||||
const byId = new Set(registered.map((entry) => entry.id));
|
||||
// Staging registers the staged runtime, the managed-home copy-back, and the
|
||||
// per-session staging lease. Both bridge starts register their handles.
|
||||
expect(byId).toEqual(
|
||||
new Set<ResourceId>(["staged_runtime", "managed_home", "staging_lease", "control_bridge", "agent_bridge"]),
|
||||
);
|
||||
// The staged runtime is startup-rollback until a clean turn promotes it; the
|
||||
// copy-back, the lease, and the bridges live through the whole run.
|
||||
const scopeOf = (id: ResourceId) => registered.find((entry) => entry.id === id)?.scope;
|
||||
expect(scopeOf("staged_runtime")).toBe("startup_rollback");
|
||||
expect(scopeOf("managed_home")).toBe("per_run");
|
||||
expect(scopeOf("staging_lease")).toBe("per_run");
|
||||
expect(scopeOf("control_bridge")).toBe("per_run");
|
||||
expect(scopeOf("agent_bridge")).toBe("per_run");
|
||||
});
|
||||
|
||||
it("test_sandbox_site_preserves_bridge_overlap_and_callback_sequencing", async () => {
|
||||
const events: string[] = [];
|
||||
let releasePaperclip!: () => void;
|
||||
const paperclipGate = new Promise<void>((resolve) => {
|
||||
releasePaperclip = resolve;
|
||||
});
|
||||
let processLaunchEnv: Record<string, string> | null = null;
|
||||
const { site } = makeSite({
|
||||
startPaperclipBridge: async () => {
|
||||
events.push("paperclip:start");
|
||||
await paperclipGate;
|
||||
events.push("paperclip:env-ready");
|
||||
return { env: { PAPERCLIP_API_KEY: "run-token" }, stop: async () => {} } as never;
|
||||
},
|
||||
startProcessSessionBridge: async ({ launchEnv }) => {
|
||||
events.push("process-session:start");
|
||||
// The launch awaits the sequencing point, so it observes the merged env.
|
||||
processLaunchEnv = await launchEnv();
|
||||
events.push("process-session:launch");
|
||||
return { agentCommand: null, stop: async () => {} } as never;
|
||||
},
|
||||
});
|
||||
|
||||
await site.placeWorkspace(makeContext("s"));
|
||||
const transportPromise = site.startTransport(makeContext("s"));
|
||||
// Both bridges start before the paperclip env resolves: their setup overlaps.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(events).toContain("paperclip:start");
|
||||
expect(events).toContain("process-session:start");
|
||||
expect(events).not.toContain("process-session:launch");
|
||||
|
||||
// Release the paperclip env; the process-session launch now observes the
|
||||
// merged run-scoped env (the single sequencing point).
|
||||
releasePaperclip();
|
||||
const transport = await transportPromise;
|
||||
expect(events.indexOf("paperclip:env-ready")).toBeLessThan(events.indexOf("process-session:launch"));
|
||||
expect(processLaunchEnv).toEqual({ BASE: "1", CODEX_HOME: "/remote/home", PAPERCLIP_API_KEY: "run-token" });
|
||||
expect(transport.launchEnv).toEqual(processLaunchEnv);
|
||||
});
|
||||
|
||||
it("test_sandbox_site_borrow_yields_files_and_runtime_is_still_created", async () => {
|
||||
const cachedRuntime = makeStagedRuntime("cached");
|
||||
const stagedRuntimes = new Map<string, StagedRuntimeStoreEntry>([
|
||||
[
|
||||
"paperclip:c:a:t:fp",
|
||||
{
|
||||
stagedRuntime: cachedRuntime,
|
||||
envDelta: { CODEX_HOME: "/remote/home" },
|
||||
teardown: async () => {},
|
||||
dispose: async () => {},
|
||||
lastUsedAt: 500,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const { site, env } = makeSite({ stagedRuntimes, isCompatibleResume: true });
|
||||
|
||||
// A borrow reads the staged entry without removal, so an overlapping run of
|
||||
// the same session still reads it.
|
||||
const borrowed = site.reuse().borrow("paperclip:c:a:t:fp");
|
||||
expect(borrowed?.stagedRuntime).toBe(cachedRuntime);
|
||||
expect(stagedRuntimes.has("paperclip:c:a:t:fp")).toBe(true);
|
||||
|
||||
// A reuse hit yields the staged FILES (not a live runtime): `placeWorkspace`
|
||||
// reports `reused` and re-applies the seam env delta, and the reuse candidate
|
||||
// names the staged files only. So the run still creates the runtime and runs
|
||||
// the handshake against the reused files.
|
||||
await site.placeWorkspace(makeContext("paperclip:c:a:t:fp"));
|
||||
expect(site.staged?.reused).toBe(true);
|
||||
expect(site.staged?.stagedRuntime).toBe(cachedRuntime);
|
||||
expect(env.CODEX_HOME).toBe("/remote/home");
|
||||
const candidate: SandboxReuseCandidate = site.reuseCandidate(makeReady(cachedRuntime));
|
||||
expect(candidate).toEqual({ stagedRuntime: cachedRuntime });
|
||||
expect("runtime" in candidate).toBe(false);
|
||||
expect("sessionHandle" in candidate).toBe(false);
|
||||
});
|
||||
|
||||
it("test_site_plan_supplies_session_cwd_spawn_cwd_and_tracing_before_the_fingerprint_build", () => {
|
||||
const { site } = makeSite();
|
||||
// `plan()` is synchronous and reads no staged state, so it runs before the
|
||||
// fingerprint build. It binds the session to the in-sandbox cwd, redirects the
|
||||
// relay-proxy spawn to the host cwd, and enables sandbox tracing.
|
||||
expect(site.plan(makeContext("s"))).toEqual({
|
||||
sessionCwd: "/remote/workspace",
|
||||
spawnCwd: "/host/workspace",
|
||||
tracingEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("test_bridge_contribution_is_not_retained_by_site_ledger_or_reuse_payload", async () => {
|
||||
const { site, registered } = makeSite();
|
||||
await site.placeWorkspace(makeContext("s"));
|
||||
const transport = await site.startTransport(makeContext("s"));
|
||||
|
||||
// The ledger holds the bridge HANDLES, never the run-scoped token env.
|
||||
const control = registered.find((entry) => entry.id === "control_bridge");
|
||||
expect(control?.payload).toBe(transport.controlBridge);
|
||||
// The staged reuse payload carries the staged files only — no credential
|
||||
// material and no bridge token.
|
||||
const candidate = site.reuseCandidate(makeReady(site.staged!.stagedRuntime));
|
||||
expect(Object.keys(candidate)).toEqual(["stagedRuntime"]);
|
||||
|
||||
// Compile-time (Amendment B): a run-scoped contribution is not assignable to
|
||||
// the staged reuse payload. This function never runs; the assertion holds at
|
||||
// typecheck only. A runtime call on a synthetic value would crash.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function _staged_reuse_payload_rejects_a_run_scoped_contribution(
|
||||
contribution: LaunchEnvironmentContribution,
|
||||
): void {
|
||||
// @ts-expect-error a launch-environment contribution is not a staged reuse payload
|
||||
const payload: SandboxReuseCandidate = contribution;
|
||||
void payload;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
// The sandbox run site.
|
||||
//
|
||||
// The sandbox site runs the agent in a remote runner. It owns the four concerns
|
||||
// the host site never has: it stages the workspace and the managed home into the
|
||||
// sandbox, it holds the per-session staging lease, it starts both host-side
|
||||
// bridges, and it syncs the managed home back on teardown. Its store keeps the
|
||||
// staged files warm across runs of the same session, so a compatible resume
|
||||
// reuses the already-staged in-sandbox runtime instead of re-shipping.
|
||||
//
|
||||
// The site owns the orchestration and the policy: the staging lease, the reuse
|
||||
// and stale decision, the ledger registrations, the launch-environment envelope,
|
||||
// the bridge overlap, and the single callback-environment sequencing point. The
|
||||
// engine injects the leaf input/output primitives — the workspace stage call, the
|
||||
// managed-home seam, the two bridge starts, and the step timer — so this module
|
||||
// stays free of the engine's private staging helpers and the unit tests drive it
|
||||
// with fakes.
|
||||
//
|
||||
// Amendment B (credential scope): `startTransport` builds the run-scoped bridge
|
||||
// contribution and hands it to the injected `finalizeLaunchEnv`, the sole
|
||||
// consumer of a contribution. The site, its ledger entries, and its reuse
|
||||
// candidate never retain a run-scoped contribution or a bridge token. The staged
|
||||
// reuse payload carries the staged files only.
|
||||
|
||||
import type {
|
||||
AdapterExecutionTarget,
|
||||
AdapterExecutionTargetPaperclipBridgeHandle,
|
||||
AdapterExecutionTargetProcessSessionBridgeHandle,
|
||||
AdapterManagedRuntimeAsset,
|
||||
PreparedAdapterExecutionTargetRuntime,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import type {
|
||||
AcpRunContext,
|
||||
AcquiredRunResources,
|
||||
LaunchEnvironmentContribution,
|
||||
PlacedWorkspace,
|
||||
ReadyRunResources,
|
||||
RunScopedContribution,
|
||||
RunSiteTransport,
|
||||
SandboxReuseCandidate,
|
||||
SessionReuseStore,
|
||||
SitePlan,
|
||||
} from "./run-contracts.js";
|
||||
import { createSessionReuseStore } from "./session-reuse-store.js";
|
||||
|
||||
/**
|
||||
* One staged in-sandbox runtime the store keeps for the next compatible resume.
|
||||
* It is the value the staged-runtime map holds. The engine still owns this shape;
|
||||
* the store reads its `lastUsedAt` and its `dispose` through the config
|
||||
* accessors. The staged cache has no per-entry idle timer, so the entry carries
|
||||
* none — the run-start sweep is the only eviction path.
|
||||
*/
|
||||
export interface StagedRuntimeStoreEntry {
|
||||
stagedRuntime: PreparedAdapterExecutionTargetRuntime;
|
||||
envDelta: Record<string, string>;
|
||||
teardown: (() => Promise<void>) | null;
|
||||
dispose: (() => Promise<void>) | null;
|
||||
lastUsedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of one workspace stage into the sandbox. The site records these
|
||||
* fields on the run and, after a clean turn, into the staged store.
|
||||
*/
|
||||
export interface StagedWorkspace {
|
||||
readonly stagedRuntime: PreparedAdapterExecutionTargetRuntime;
|
||||
readonly teardown: (() => Promise<void>) | null;
|
||||
readonly dispose: (() => Promise<void>) | null;
|
||||
readonly envDelta: Record<string, string>;
|
||||
/** True when the run reused an already-staged runtime rather than staging fresh. */
|
||||
readonly reused: boolean;
|
||||
}
|
||||
|
||||
/** The leaf staging primitive the engine injects. It ships one asset set. */
|
||||
export type StageWorkspace = (assets: AdapterManagedRuntimeAsset[]) => Promise<PreparedAdapterExecutionTargetRuntime>;
|
||||
|
||||
/**
|
||||
* The managed-home seam result, in the shape the site consumes. The engine maps
|
||||
* its adapter seam onto this before it passes the seam in.
|
||||
*/
|
||||
export interface ManagedHomeSeamResult {
|
||||
readonly stagedRuntime: PreparedAdapterExecutionTargetRuntime;
|
||||
readonly teardown: (() => Promise<void>) | null;
|
||||
readonly dispose: (() => Promise<void>) | null;
|
||||
}
|
||||
|
||||
/** The construction options for the sandbox site. Every value is per run. */
|
||||
export interface SandboxRunSiteOptions {
|
||||
/** The run ledger. `executeAcpxEngine` creates it and passes it in (one ledger). */
|
||||
readonly ledger: AcquiredRunResources;
|
||||
/** The staged-runtime map the store operates over. The engine owns it across runs. */
|
||||
readonly stagedRuntimes: Map<string, StagedRuntimeStoreEntry>;
|
||||
/** The per-key staging mutex the lease and the sweep share. */
|
||||
readonly stagingLocks: Map<string, Promise<unknown>>;
|
||||
readonly now: () => number;
|
||||
/** The staged-idle bound, in milliseconds. `0` or less disables the sweep. */
|
||||
readonly idleMs: number;
|
||||
/** The in-sandbox workspace dir the session binds to (a fingerprint input). */
|
||||
readonly sessionCwd: string;
|
||||
/** The host spawn cwd for the relay proxy on the remote process-session lane. */
|
||||
readonly spawnCwd: string;
|
||||
/** The remote runner-backed sandbox target. */
|
||||
readonly target: AdapterExecutionTarget;
|
||||
/**
|
||||
* The run env the site mutates in place. Staging repoints the managed-home env
|
||||
* var onto the in-sandbox home here, and the site captures the delta from it.
|
||||
*/
|
||||
readonly env: Record<string, string>;
|
||||
/**
|
||||
* True when the supplied session params resume THIS staged session. Only a
|
||||
* compatible resume may reuse the already-staged runtime.
|
||||
*/
|
||||
readonly isCompatibleResume: boolean;
|
||||
/** Ship the workspace (and, via the seam, the managed home) into the sandbox. */
|
||||
readonly stage: StageWorkspace;
|
||||
/**
|
||||
* Seed the managed home through `stage`, repoint the home env var, and wire the
|
||||
* per-run copy-back. Absent for custom agents and the shared-engine tests, where
|
||||
* the site stages the workspace with no home asset.
|
||||
*/
|
||||
readonly seedManagedHome?: (stage: StageWorkspace) => Promise<ManagedHomeSeamResult>;
|
||||
/**
|
||||
* Remove a freshly staged in-sandbox runtime after the seam threw. The site
|
||||
* fires it when the seam fails after a successful stage.
|
||||
*/
|
||||
readonly disposeFreshStagedRuntime: (stagedRuntime: PreparedAdapterExecutionTargetRuntime) => Promise<void>;
|
||||
/** Wrap the `stage.sync` step in the run's startup step timer. */
|
||||
readonly measureStageStep: <T>(run: () => Promise<T>) => Promise<T>;
|
||||
/** Publish the referenced-project workspace hints after a fresh stage. */
|
||||
readonly publishStagedProjectHints: (stagedProjectDirs: Record<string, string>) => void;
|
||||
readonly onReuseLog: () => Promise<void>;
|
||||
|
||||
/** Start the host-side paperclip callback bridge. */
|
||||
readonly startPaperclipBridge: (
|
||||
runtimeRootDir: string | null,
|
||||
) => Promise<AdapterExecutionTargetPaperclipBridgeHandle | null>;
|
||||
/** Start the host-side process-session bridge with a deferred launch env. */
|
||||
readonly startProcessSessionBridge: (input: {
|
||||
runtimeRootDir: string | null;
|
||||
launchEnv: () => Promise<Record<string, string>>;
|
||||
}) => Promise<AdapterExecutionTargetProcessSessionBridgeHandle | null>;
|
||||
/** Wrap each concurrent bridge start in the run's startup step timer. */
|
||||
readonly measureBridgeStep: <T>(step: "bridge.paperclip" | "bridge.process-session", run: () => Promise<T>) => Promise<T>;
|
||||
/**
|
||||
* Finalize the run's branded launch environment from the bridge contribution.
|
||||
* The engine owns `finalizeLaunchEnvironment`, so it stays the sole consumer of
|
||||
* a contribution; the site calls this capability at the sequencing point.
|
||||
*/
|
||||
readonly finalizeLaunchEnv: (contributions: readonly LaunchEnvironmentContribution[]) => Record<string, string>;
|
||||
readonly onPaperclipBridgeLog: () => Promise<void>;
|
||||
|
||||
/** Stop both bridges and run the managed-home copy-back on teardown. */
|
||||
readonly stopBridges: (input: {
|
||||
controlBridge: AdapterExecutionTargetPaperclipBridgeHandle | null;
|
||||
agentBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/** The transport the sandbox site started, plus the run-scoped launch env. */
|
||||
export interface SandboxRunSiteTransport extends RunSiteTransport {
|
||||
/** The finalized launch env for the process launch. */
|
||||
readonly launchEnv: Record<string, string>;
|
||||
}
|
||||
|
||||
/** The sandbox run site. It owns staging, the lease, both bridges, and sync-back. */
|
||||
export interface SandboxRunSite {
|
||||
readonly kind: "sandbox";
|
||||
plan(context: AcpRunContext): SitePlan;
|
||||
placeWorkspace(context: AcpRunContext): Promise<PlacedWorkspace>;
|
||||
startTransport(context: AcpRunContext): Promise<SandboxRunSiteTransport>;
|
||||
reuse(): SessionReuseStore<StagedRuntimeStoreEntry>;
|
||||
reuseCandidate(resources: ReadyRunResources): SandboxReuseCandidate;
|
||||
syncBack(context: AcpRunContext): Promise<void>;
|
||||
/** The staged workspace the run installed or reused, or null before `placeWorkspace`. */
|
||||
readonly staged: StagedWorkspace | null;
|
||||
/**
|
||||
* The paperclip callback bridge the run started, or null before
|
||||
* `startTransport` or on the host lane. `startTransport` sets it before it
|
||||
* rethrows a partial-bring-up failure, so an abandon path can stop the bridge
|
||||
* that started when its sibling threw.
|
||||
*/
|
||||
readonly controlBridge: AdapterExecutionTargetPaperclipBridgeHandle | null;
|
||||
/** The process-session bridge the run started, or null before `startTransport`. */
|
||||
readonly agentBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null;
|
||||
/**
|
||||
* Release the per-session staging lease `placeWorkspace` acquired, or null
|
||||
* before `placeWorkspace`. An abandon path calls it to free the lease when the
|
||||
* run never reaches sync-back.
|
||||
*/
|
||||
readonly stagingLeaseRelease: (() => void) | null;
|
||||
}
|
||||
|
||||
/** Create the sandbox run site over the engine's staged-runtime map and ledger. */
|
||||
export function createSandboxRunSite(options: SandboxRunSiteOptions): SandboxRunSite {
|
||||
const store = createSessionReuseStore<StagedRuntimeStoreEntry>({
|
||||
entries: options.stagedRuntimes,
|
||||
now: options.now,
|
||||
idleMs: options.idleMs,
|
||||
lastUsedAt: (entry) => entry.lastUsedAt,
|
||||
// The staged cache arms no per-entry timer; the run-start sweep is the only
|
||||
// eviction path, and it runs under the per-key staging lease.
|
||||
release: async (entry) => {
|
||||
if (entry.dispose) await entry.dispose().catch(() => {});
|
||||
},
|
||||
withEvictLease: async (key, critical) => {
|
||||
const lease = await withStagingLease(options.stagingLocks, key, critical);
|
||||
lease.release();
|
||||
},
|
||||
});
|
||||
|
||||
// The lease the run holds from the stage-or-reuse decision through the active
|
||||
// turn until `syncBack` releases it. Held on the run, not on the store, because
|
||||
// a run keeps its lease across its whole lifetime.
|
||||
let leaseRelease: (() => void) | null = null;
|
||||
let staged: StagedWorkspace | null = null;
|
||||
let controlBridge: AdapterExecutionTargetPaperclipBridgeHandle | null = null;
|
||||
let agentBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null = null;
|
||||
|
||||
return {
|
||||
kind: "sandbox",
|
||||
|
||||
plan(): SitePlan {
|
||||
// The sandbox binds the session to the in-sandbox workspace dir, redirects
|
||||
// the relay proxy spawn to the host cwd, and emits real sandbox trace spans.
|
||||
return {
|
||||
sessionCwd: options.sessionCwd,
|
||||
spawnCwd: options.spawnCwd,
|
||||
tracingEnabled: true,
|
||||
};
|
||||
},
|
||||
|
||||
async placeWorkspace(context: AcpRunContext): Promise<PlacedWorkspace> {
|
||||
const sessionKey = context.sessionKey;
|
||||
const lease = await withStagingLease(options.stagingLocks, sessionKey, async (): Promise<StagedWorkspace> => {
|
||||
const cached = options.isCompatibleResume ? store.borrow(sessionKey) : undefined;
|
||||
if (cached) {
|
||||
// Reuse the already-staged in-sandbox workspace and managed home.
|
||||
// Re-apply the env keys the seam repointed and reuse the per-run
|
||||
// copy-back so the copy-back cadence stays exactly per run.
|
||||
Object.assign(options.env, cached.envDelta);
|
||||
cached.lastUsedAt = options.now();
|
||||
await options.onReuseLog();
|
||||
return {
|
||||
stagedRuntime: cached.stagedRuntime,
|
||||
teardown: cached.teardown,
|
||||
dispose: cached.dispose,
|
||||
envDelta: cached.envDelta,
|
||||
reused: true,
|
||||
};
|
||||
}
|
||||
// Not a compatible resume: stage fresh. Drop and dispose a stale entry
|
||||
// that collides on this key first, so a fresh stage neither reuses nor
|
||||
// leaks it.
|
||||
const stale = options.stagedRuntimes.get(sessionKey);
|
||||
if (stale) {
|
||||
options.stagedRuntimes.delete(sessionKey);
|
||||
if (stale.dispose) await stale.dispose().catch(() => {});
|
||||
}
|
||||
const envBeforeStage = { ...options.env };
|
||||
let freshlyStaged: PreparedAdapterExecutionTargetRuntime | null = null;
|
||||
const stage: StageWorkspace = async (assets) => {
|
||||
const result = await options.stage(assets);
|
||||
freshlyStaged = result;
|
||||
return result;
|
||||
};
|
||||
const seam = await options.measureStageStep(async (): Promise<ManagedHomeSeamResult> => {
|
||||
if (options.seedManagedHome) {
|
||||
try {
|
||||
return await options.seedManagedHome(stage);
|
||||
} catch (seamErr) {
|
||||
// The seam threw after a possible successful stage. Dispose the fresh
|
||||
// staged runtime so the abandoned in-sandbox managed home never leaks,
|
||||
// then rethrow the seam error.
|
||||
if (freshlyStaged) await options.disposeFreshStagedRuntime(freshlyStaged);
|
||||
throw seamErr;
|
||||
}
|
||||
}
|
||||
return { stagedRuntime: await stage([]), teardown: null, dispose: null };
|
||||
});
|
||||
const envDelta: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(options.env)) {
|
||||
if (envBeforeStage[key] !== value) envDelta[key] = value;
|
||||
}
|
||||
return {
|
||||
stagedRuntime: seam.stagedRuntime,
|
||||
teardown: seam.teardown,
|
||||
dispose: seam.dispose,
|
||||
envDelta,
|
||||
reused: false,
|
||||
};
|
||||
});
|
||||
leaseRelease = lease.release;
|
||||
staged = lease.value;
|
||||
// Publish the referenced-project workspace hints (known only after staging).
|
||||
const stagedProjectDirs = staged.stagedRuntime.additionalSourceDirs ?? {};
|
||||
if (Object.keys(stagedProjectDirs).length > 0) {
|
||||
options.publishStagedProjectHints(stagedProjectDirs);
|
||||
}
|
||||
// Register the acquired resources into the ledger. A per-run copy-back and a
|
||||
// staging lease live through the whole run; the staged runtime is
|
||||
// startup-rollback until a clean turn promotes it.
|
||||
options.ledger.register({
|
||||
id: "staged_runtime",
|
||||
payload: { stagedRuntime: staged.stagedRuntime, disposeStaged: staged.dispose ?? (async () => {}) },
|
||||
scope: "startup_rollback",
|
||||
});
|
||||
if (staged.teardown) {
|
||||
options.ledger.register({
|
||||
id: "managed_home",
|
||||
payload: { teardown: staged.teardown, envDelta: staged.envDelta },
|
||||
scope: "per_run",
|
||||
});
|
||||
}
|
||||
if (leaseRelease) {
|
||||
const release = leaseRelease;
|
||||
options.ledger.register({
|
||||
id: "staging_lease",
|
||||
payload: { release },
|
||||
scope: "per_run",
|
||||
});
|
||||
}
|
||||
return {
|
||||
referencedProjectStagingFailures: (staged.stagedRuntime.additionalSourceFailures ?? []).map(
|
||||
(failure) => ({ projectId: failure.projectId }),
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
async startTransport(): Promise<SandboxRunSiteTransport> {
|
||||
// Bring up both host-side bridges concurrently. Their remote subtrees are
|
||||
// disjoint, so their env-independent setup overlaps. The one real
|
||||
// dependency — the paperclip bridge's returned env must reach the
|
||||
// process-session launch — is sequenced by `launchEnv`, a memoized thunk the
|
||||
// process-session bridge awaits right before its launch.
|
||||
const stagedRootDir = staged?.stagedRuntime.runtimeRootDir ?? null;
|
||||
const paperclipStart = options.measureBridgeStep("bridge.paperclip", () =>
|
||||
options.startPaperclipBridge(stagedRootDir),
|
||||
);
|
||||
// The single sequencing point (paperclip env → process-session launch),
|
||||
// memoized so the merge runs exactly once whether the process-session bridge
|
||||
// consumes it at launch or `startTransport` finalizes it below.
|
||||
let launchEnvPromise: Promise<Record<string, string>> | null = null;
|
||||
let launchEnv: Record<string, string> = {};
|
||||
const finalizeLaunchEnv = (): Promise<Record<string, string>> =>
|
||||
(launchEnvPromise ??= (async () => {
|
||||
const paperclip = await paperclipStart;
|
||||
// The paperclip bridge token is run-scoped: it lives for this run only and
|
||||
// never enters a reuse payload (Amendment B). The site hands it to the
|
||||
// engine's `finalizeLaunchEnv`, the sole consumer of a contribution, and
|
||||
// retains nothing.
|
||||
const contributions: LaunchEnvironmentContribution[] = [];
|
||||
if (paperclip) {
|
||||
contributions.push({ scope: "run", env: paperclip.env } as unknown as RunScopedContribution);
|
||||
await options.onPaperclipBridgeLog();
|
||||
}
|
||||
launchEnv = options.finalizeLaunchEnv(contributions);
|
||||
return launchEnv;
|
||||
})());
|
||||
const processSessionStart = options.measureBridgeStep("bridge.process-session", () =>
|
||||
options.startProcessSessionBridge({ runtimeRootDir: stagedRootDir, launchEnv: finalizeLaunchEnv }),
|
||||
);
|
||||
// Settle BOTH starts, so a partial failure can stop whichever bridge started.
|
||||
const [paperclip, processSession] = await Promise.allSettled([paperclipStart, processSessionStart]);
|
||||
controlBridge = paperclip.status === "fulfilled" ? paperclip.value : null;
|
||||
agentBridge = processSession.status === "fulfilled" ? processSession.value : null;
|
||||
const failure =
|
||||
paperclip.status === "rejected"
|
||||
? paperclip.reason
|
||||
: processSession.status === "rejected"
|
||||
? processSession.reason
|
||||
: null;
|
||||
if (failure) throw failure;
|
||||
// Guarantee the merge ran even if the process-session bridge returned without
|
||||
// consuming the launch env (memoized, so a no-op if it did).
|
||||
await finalizeLaunchEnv();
|
||||
if (controlBridge) {
|
||||
options.ledger.register({ id: "control_bridge", payload: controlBridge, scope: "per_run" });
|
||||
}
|
||||
if (agentBridge) {
|
||||
options.ledger.register({ id: "agent_bridge", payload: agentBridge, scope: "per_run" });
|
||||
}
|
||||
return { controlBridge, agentBridge, launchEnv };
|
||||
},
|
||||
|
||||
reuse(): SessionReuseStore<StagedRuntimeStoreEntry> {
|
||||
return store;
|
||||
},
|
||||
|
||||
reuseCandidate(resources: ReadyRunResources): SandboxReuseCandidate {
|
||||
const stagedResource = resources.get("staged_runtime");
|
||||
if (!stagedResource) {
|
||||
throw new Error("sandbox run site cannot name a reuse candidate without a staged_runtime resource");
|
||||
}
|
||||
// The reuse payload carries the staged files only — no credential material.
|
||||
return { stagedRuntime: stagedResource.stagedRuntime };
|
||||
},
|
||||
|
||||
async syncBack(): Promise<void> {
|
||||
try {
|
||||
// Stop both bridges, then run the managed-home copy-back (mirrors the CLI
|
||||
// finally: stop bridge, then restore workspace).
|
||||
await options.stopBridges({ controlBridge, agentBridge });
|
||||
} finally {
|
||||
// Release the per-session staging lease in a finally, so an earlier
|
||||
// teardown fault never strands it and deadlocks the next same-session run.
|
||||
leaseRelease?.();
|
||||
leaseRelease = null;
|
||||
}
|
||||
},
|
||||
|
||||
get staged(): StagedWorkspace | null {
|
||||
return staged;
|
||||
},
|
||||
|
||||
get controlBridge(): AdapterExecutionTargetPaperclipBridgeHandle | null {
|
||||
return controlBridge;
|
||||
},
|
||||
|
||||
get agentBridge(): AdapterExecutionTargetProcessSessionBridgeHandle | null {
|
||||
return agentBridge;
|
||||
},
|
||||
|
||||
get stagingLeaseRelease(): (() => void) | null {
|
||||
return leaseRelease;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Per-`sessionKey` async lease: chains each caller after the previous one so the
|
||||
// stage-or-reuse decision for a session runs serially, then keeps the lease held
|
||||
// until the run releases it. Two overlapping runs of the same session can never
|
||||
// stage into the same remote workspace at once: the loser waits, then re-checks
|
||||
// the cache before it decides.
|
||||
async function withStagingLease<T>(
|
||||
locks: Map<string, Promise<unknown>>,
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<{ value: T; release: () => void }> {
|
||||
const prev = locks.get(key) ?? Promise.resolve();
|
||||
let releaseGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
const mine: Promise<unknown> = prev.then(() => gate);
|
||||
locks.set(key, mine);
|
||||
await prev.catch(() => {});
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
releaseGate();
|
||||
if (locks.get(key) === mine) locks.delete(key);
|
||||
};
|
||||
try {
|
||||
return { value: await fn(), release };
|
||||
} catch (error) {
|
||||
if (!released) release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Tests for the generic per-session reuse store (Phase 18).
|
||||
//
|
||||
// The store backs both site caches over a caller-provided map. These tests pin
|
||||
// the four behaviors the two caches had before the refactor: borrow reads
|
||||
// without removal and clears the per-entry idle timer; discard is
|
||||
// identity-guarded and releases once; save arms a per-entry idle timer that
|
||||
// discards without a run; and the run-start sweep runs each eviction under the
|
||||
// optional per-key lease.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSessionReuseStore } from "./session-reuse-store.js";
|
||||
|
||||
interface Entry {
|
||||
readonly id: string;
|
||||
lastUsedAt: number;
|
||||
cleanupTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
function hostConfig(entries: Map<string, Entry>, released: Entry[], idleMs: number) {
|
||||
return {
|
||||
entries,
|
||||
now: () => Date.now(),
|
||||
idleMs,
|
||||
lastUsedAt: (entry: Entry) => entry.lastUsedAt,
|
||||
getTimer: (entry: Entry) => entry.cleanupTimer,
|
||||
setTimer: (entry: Entry, timer: ReturnType<typeof setTimeout> | undefined) => {
|
||||
entry.cleanupTimer = timer;
|
||||
},
|
||||
perEntryTimer: true,
|
||||
release: (entry: Entry) => {
|
||||
released.push(entry);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("session reuse store", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("test_borrow_reads_without_removal_and_clears_the_per_entry_idle_timer", async () => {
|
||||
const entries = new Map<string, Entry>();
|
||||
const released: Entry[] = [];
|
||||
const store = createSessionReuseStore<Entry>(hostConfig(entries, released, 1000));
|
||||
|
||||
const entry: Entry = { id: "a", lastUsedAt: Date.now() };
|
||||
store.save("k", entry);
|
||||
expect(entry.cleanupTimer).toBeDefined();
|
||||
|
||||
// A borrow reads the entry and leaves it in the store, so a second borrow
|
||||
// still finds it.
|
||||
expect(store.borrow("k")).toBe(entry);
|
||||
expect(store.borrow("k")).toBe(entry);
|
||||
|
||||
// The borrow cleared the per-entry idle timer, so the entry does not expire
|
||||
// while it is in use. Advance well past the idle bound.
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(released).toEqual([]);
|
||||
expect(store.borrow("k")).toBe(entry);
|
||||
// The entry stays in the caller's map for a later run to inspect.
|
||||
expect(entries.get("k")).toBe(entry);
|
||||
});
|
||||
|
||||
it("test_discard_is_identity_guarded_and_fires_release_once", async () => {
|
||||
const entries = new Map<string, Entry>();
|
||||
const released: Entry[] = [];
|
||||
const store = createSessionReuseStore<Entry>({
|
||||
entries,
|
||||
now: () => Date.now(),
|
||||
idleMs: 0,
|
||||
lastUsedAt: (entry) => entry.lastUsedAt,
|
||||
release: (entry) => {
|
||||
released.push(entry);
|
||||
},
|
||||
});
|
||||
|
||||
const first: Entry = { id: "first", lastUsedAt: Date.now() };
|
||||
store.save("k", first);
|
||||
|
||||
// The first discard removes the entry and fires the release once.
|
||||
await store.discard("k");
|
||||
expect(released).toEqual([first]);
|
||||
expect(store.borrow("k")).toBeUndefined();
|
||||
|
||||
// A second discard of the same key finds no entry, so it releases nothing.
|
||||
await store.discard("k");
|
||||
expect(released).toEqual([first]);
|
||||
|
||||
// A re-saved entry is a new entry. A discard of the key releases the new
|
||||
// entry, never the already-released first entry again.
|
||||
const second: Entry = { id: "second", lastUsedAt: Date.now() };
|
||||
store.save("k", second);
|
||||
await store.discard("k");
|
||||
expect(released).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it("test_save_arms_a_per_entry_idle_timer_that_discards_without_a_run", async () => {
|
||||
const entries = new Map<string, Entry>();
|
||||
const released: Entry[] = [];
|
||||
const store = createSessionReuseStore<Entry>(hostConfig(entries, released, 1000));
|
||||
|
||||
const entry: Entry = { id: "a", lastUsedAt: Date.now() };
|
||||
store.save("k", entry);
|
||||
|
||||
// Before the idle deadline the entry is still present and unreleased.
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(released).toEqual([]);
|
||||
|
||||
// At the idle deadline the armed timer discards the entry with no run and
|
||||
// no explicit discard call.
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(released).toEqual([entry]);
|
||||
expect(store.borrow("k")).toBeUndefined();
|
||||
expect(entries.has("k")).toBe(false);
|
||||
});
|
||||
|
||||
it("test_evict_idle_sweep_runs_under_per_key_lease", async () => {
|
||||
const entries = new Map<string, Entry>();
|
||||
const released: Entry[] = [];
|
||||
const leaseOrder: string[] = [];
|
||||
let now = 0;
|
||||
const store = createSessionReuseStore<Entry>({
|
||||
entries,
|
||||
now: () => now,
|
||||
idleMs: 1000,
|
||||
lastUsedAt: (entry) => entry.lastUsedAt,
|
||||
// The sandbox lane leaves the per-entry timer off and relies on the
|
||||
// run-start sweep only.
|
||||
release: (entry) => {
|
||||
released.push(entry);
|
||||
},
|
||||
withEvictLease: async (key, critical) => {
|
||||
leaseOrder.push(`lease:${key}`);
|
||||
await critical();
|
||||
leaseOrder.push(`release:${key}`);
|
||||
},
|
||||
});
|
||||
|
||||
store.save("stale", { id: "stale", lastUsedAt: 0 });
|
||||
store.save("fresh", { id: "fresh", lastUsedAt: 0 });
|
||||
|
||||
// Move the clock so only the "stale" entry crosses the idle bound. Touch
|
||||
// "fresh" so it stays inside the window.
|
||||
now = 1500;
|
||||
store.borrow("fresh");
|
||||
store.save("fresh", { id: "fresh2", lastUsedAt: now });
|
||||
|
||||
await store.evictIdle(now);
|
||||
|
||||
// Only the stale entry evicts, and its critical section ran inside the
|
||||
// per-key lease (lease acquired, critical ran, lease released, in order).
|
||||
expect(released.map((entry) => entry.id)).toEqual(["stale"]);
|
||||
expect(leaseOrder).toEqual(["lease:stale", "release:stale"]);
|
||||
expect(store.borrow("stale")).toBeUndefined();
|
||||
expect(store.borrow("fresh")).toEqual({ id: "fresh2", lastUsedAt: 1500 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
// The generic per-session reuse store.
|
||||
//
|
||||
// One `SessionReuseStore<T>` implementation backs both site caches. The host
|
||||
// site saves a live runtime and session handle. The sandbox site saves the
|
||||
// staged files. The store is generic in the saved type, so a generic `save`
|
||||
// cannot read what a site saves. `RunSite.reuseCandidate()` names the concrete
|
||||
// type per site.
|
||||
//
|
||||
// The store operates OVER a caller-provided map, not a private internal map.
|
||||
// The host lane passes its warm-handle map; the sandbox lane passes its staged-
|
||||
// runtime map. Both maps persist across runs, so a saved entry stays visible to
|
||||
// a later run of the same session. Each entry carries its own last-used time and
|
||||
// its own optional per-entry idle timer, so the store reads and writes those
|
||||
// through the caller-supplied accessors. This keeps the exact shape both caches
|
||||
// had before this refactor, so a caller that inspects the map still reads the
|
||||
// same entries.
|
||||
//
|
||||
// The store keeps the exact behavior the two caches had before this refactor:
|
||||
//
|
||||
// * `borrow` reads without removal. It keeps today's visibility for
|
||||
// overlapping runs of the same session. It clears the entry's per-entry
|
||||
// idle timer, because an in-use entry must not expire under its own timer.
|
||||
// * `save` sets the entry in the map. When the store has a per-entry idle
|
||||
// timer, `save` arms an unref'd timer that discards the entry at the idle
|
||||
// deadline without a run (today's `scheduleIdleHandleCleanup`).
|
||||
// * `discard` is an identity-guarded removal that fires the idempotent
|
||||
// release once. It acts on the entry that is at the key now, so a re-saved
|
||||
// entry is safe and a second discard is a no-op.
|
||||
// * `evictIdle` is the run-start sweep (today's `cleanupIdleHandles` and
|
||||
// `cleanupIdleStagedRuntimes`). The host lane runs the critical section
|
||||
// directly. The sandbox lane runs it under its per-key staging lease and
|
||||
// re-checks the idle window inside the lease.
|
||||
|
||||
import type { SessionReuseStore } from "./run-contracts.js";
|
||||
|
||||
/**
|
||||
* The construction options for a reuse store. The public `SessionReuseStore<T>`
|
||||
* surface stays generic; these options carry the per-lane behavior the store
|
||||
* needs but the interface does not name: the backing map, the clock, the idle
|
||||
* bound, how to read an entry's last-used time and per-entry timer, how to
|
||||
* release an entry, whether to arm a per-entry timer, and the optional eviction
|
||||
* lease.
|
||||
*/
|
||||
export interface SessionReuseStoreConfig<T> {
|
||||
/**
|
||||
* The map the store reads and writes. The caller owns it, so a caller that
|
||||
* inspects the map still reads the same entries. The map persists across runs.
|
||||
*/
|
||||
readonly entries: Map<string, T>;
|
||||
/** The clock. The store reads it for the idle deadline and the sweep. */
|
||||
readonly now: () => number;
|
||||
/**
|
||||
* The idle bound, in milliseconds. When it is `0` or less, the store arms no
|
||||
* per-entry timer and the sweep evicts nothing.
|
||||
*/
|
||||
readonly idleMs: number;
|
||||
/** Read an entry's last-used time. The store compares it to the idle bound. */
|
||||
readonly lastUsedAt: (entry: T) => number;
|
||||
/**
|
||||
* Release an entry's resources. The store calls it once per entry on a
|
||||
* discard, on a per-entry timer fire, or on an idle sweep. The store guards
|
||||
* every release with a map-identity check, so a discarded entry never
|
||||
* releases twice.
|
||||
*/
|
||||
readonly release: (entry: T) => void | Promise<void>;
|
||||
/**
|
||||
* Read an entry's per-entry idle timer, or `undefined` when it holds none.
|
||||
* The host lane supplies this; the sandbox lane omits it and arms no timer.
|
||||
*/
|
||||
readonly getTimer?: (entry: T) => ReturnType<typeof setTimeout> | undefined;
|
||||
/**
|
||||
* Write (or clear with `undefined`) an entry's per-entry idle timer. The host
|
||||
* lane supplies this; the sandbox lane omits it.
|
||||
*/
|
||||
readonly setTimer?: (entry: T, timer: ReturnType<typeof setTimeout> | undefined) => void;
|
||||
/**
|
||||
* Arm a per-entry idle timer on `save`. The host lane sets it true. The
|
||||
* sandbox lane leaves it false and relies on the run-start sweep only.
|
||||
*/
|
||||
readonly perEntryTimer?: boolean;
|
||||
/**
|
||||
* Run each eviction critical section under a per-key lease. The sandbox lane
|
||||
* supplies its per-key staging lease so the sweep re-checks the entry inside
|
||||
* the lease. The host lane omits it and the sweep runs the critical section
|
||||
* directly.
|
||||
*/
|
||||
readonly withEvictLease?: (key: string, critical: () => Promise<void>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generic per-session reuse store over the caller-provided map. The
|
||||
* store holds at most one entry per session key. Each entry carries its saved
|
||||
* value, its last-used time, and an optional per-entry idle timer, all read
|
||||
* through the config accessors.
|
||||
*/
|
||||
export function createSessionReuseStore<T>(
|
||||
config: SessionReuseStoreConfig<T>,
|
||||
): SessionReuseStore<T> {
|
||||
const { entries } = config;
|
||||
|
||||
function clearEntryTimer(entry: T): void {
|
||||
const timer = config.getTimer?.(entry);
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
config.setTimer?.(entry, undefined);
|
||||
}
|
||||
|
||||
// Release an entry once and drop it from the map when it is still the current
|
||||
// entry at the key. The map-identity guard makes the release idempotent: the
|
||||
// synchronous `delete` runs before the awaited release, so a second release of
|
||||
// the same entry finds a different or absent entry at the key and stops.
|
||||
async function releaseEntry(key: string, entry: T): Promise<void> {
|
||||
clearEntryTimer(entry);
|
||||
if (entries.get(key) !== entry) return;
|
||||
entries.delete(key);
|
||||
await config.release(entry);
|
||||
}
|
||||
|
||||
function armTimer(key: string, entry: T): void {
|
||||
if (!config.perEntryTimer || config.idleMs <= 0) return;
|
||||
clearEntryTimer(entry);
|
||||
const delayMs = Math.max(1, config.lastUsedAt(entry) + config.idleMs - config.now());
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
if (entries.get(key) !== entry) return;
|
||||
// A later touch can move `lastUsedAt` forward, so re-check the idle
|
||||
// window and re-arm the timer when the entry is still in use.
|
||||
if (config.now() - config.lastUsedAt(entry) < config.idleMs) {
|
||||
armTimer(key, entry);
|
||||
return;
|
||||
}
|
||||
await releaseEntry(key, entry);
|
||||
})();
|
||||
}, delayMs);
|
||||
timer.unref?.();
|
||||
config.setTimer?.(entry, timer);
|
||||
}
|
||||
|
||||
return {
|
||||
borrow(sessionKey: string): T | undefined {
|
||||
const entry = entries.get(sessionKey);
|
||||
if (entry === undefined) return undefined;
|
||||
// The entry is in use now, so clear its per-entry idle timer. The next
|
||||
// `save` re-arms a fresh timer after the run finishes.
|
||||
clearEntryTimer(entry);
|
||||
return entry;
|
||||
},
|
||||
|
||||
save(sessionKey: string, entry: T): void {
|
||||
// The caller sets the entry's last-used time before it saves, so `save`
|
||||
// sets the entry as-is and arms its timer from that time.
|
||||
entries.set(sessionKey, entry);
|
||||
armTimer(sessionKey, entry);
|
||||
},
|
||||
|
||||
discard(sessionKey: string): Promise<void> {
|
||||
const entry = entries.get(sessionKey);
|
||||
// `releaseEntry` deletes the key synchronously and then fires the release,
|
||||
// so the returned promise lets the caller await the release when it must
|
||||
// finish before a downstream resource release.
|
||||
if (entry === undefined) return Promise.resolve();
|
||||
return releaseEntry(sessionKey, entry);
|
||||
},
|
||||
|
||||
async evictIdle(now: number): Promise<void> {
|
||||
if (config.idleMs <= 0) return;
|
||||
const stale: Array<[string, T]> = [];
|
||||
for (const [key, entry] of entries.entries()) {
|
||||
if (now - config.lastUsedAt(entry) >= config.idleMs) stale.push([key, entry]);
|
||||
}
|
||||
for (const [key, entry] of stale) {
|
||||
const critical = async (): Promise<void> => {
|
||||
if (entries.get(key) !== entry) return;
|
||||
// Under a lease the sweep re-checks the idle window, so a run that
|
||||
// re-saved the key while the lease was held keeps its entry.
|
||||
if (config.withEvictLease && config.now() - config.lastUsedAt(entry) < config.idleMs) {
|
||||
return;
|
||||
}
|
||||
await releaseEntry(key, entry);
|
||||
};
|
||||
if (config.withEvictLease) {
|
||||
await config.withEvictLease(key, critical);
|
||||
} else {
|
||||
await critical();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decideReuse,
|
||||
settleAcpRun,
|
||||
type ReuseCandidateInput,
|
||||
type ReuseDecision,
|
||||
type SettlementSlots,
|
||||
type SettlementSteps,
|
||||
} from "./settlement-sequence.js";
|
||||
import { createRunResourceLedger } from "./run-resource-ledger.js";
|
||||
import type { AcquiredRunResources, ResourceId, SettlementCause } from "./run-contracts.js";
|
||||
|
||||
// The settlement sequence is the one cleanup owner. These tests drive it with
|
||||
// fakes for the steps and assert the claim, the order, the Phase 3 error policy,
|
||||
// the staging-lease finally, and the Amendment B credential gate. They never
|
||||
// touch the engine's real run state.
|
||||
|
||||
// Register a per-run resource for each id, with an optional payload the steps
|
||||
// read. The tests do not seal the ledger; `takeForSettlement` runs from `open`.
|
||||
function ledgerWith(
|
||||
ids: readonly ResourceId[],
|
||||
payloads: Partial<Record<ResourceId, unknown>> = {},
|
||||
): AcquiredRunResources {
|
||||
const ledger = createRunResourceLedger();
|
||||
const register = ledger.register as (registration: {
|
||||
id: ResourceId;
|
||||
payload: unknown;
|
||||
scope: "per_run";
|
||||
}) => void;
|
||||
for (const id of ids) {
|
||||
register({ id, payload: payloads[id] ?? {}, scope: "per_run" });
|
||||
}
|
||||
return ledger;
|
||||
}
|
||||
|
||||
// Count the claims so a test proves the sequence claims the ledger once.
|
||||
function countingLedger(inner: AcquiredRunResources): {
|
||||
ledger: AcquiredRunResources;
|
||||
claims: () => number;
|
||||
} {
|
||||
let claims = 0;
|
||||
const ledger: AcquiredRunResources = {
|
||||
register: inner.register.bind(inner),
|
||||
seal: inner.seal.bind(inner),
|
||||
takeForSettlement: () => {
|
||||
claims += 1;
|
||||
return inner.takeForSettlement();
|
||||
},
|
||||
};
|
||||
return { ledger, claims: () => claims };
|
||||
}
|
||||
|
||||
// A base set of steps whose bodies record their name. Each test overrides the
|
||||
// steps it drives.
|
||||
function baseSteps(order: string[], overrides: Partial<SettlementSteps> = {}): SettlementSteps {
|
||||
return {
|
||||
reuseCandidate: overrides.reuseCandidate ?? (() => null),
|
||||
endSession: overrides.endSession ?? (() => {
|
||||
order.push("end_session");
|
||||
}),
|
||||
settleReuse: overrides.settleReuse ?? (() => {
|
||||
order.push("settle_reuse");
|
||||
}),
|
||||
stopTransport: overrides.stopTransport ?? (() => {
|
||||
order.push("stop_transport");
|
||||
}),
|
||||
syncBack: overrides.syncBack ?? (() => {
|
||||
order.push("sync_back");
|
||||
}),
|
||||
releaseStagingLease: overrides.releaseStagingLease ?? (() => {
|
||||
order.push("release_staging_lease");
|
||||
}),
|
||||
recordError: overrides.recordError ?? (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
const cleanTurnCause: SettlementCause | null = null;
|
||||
|
||||
describe("ACPX settlement sequence", () => {
|
||||
it("test_settlement_claims_ledger_once_then_runs_the_steps_in_order", async () => {
|
||||
const order: string[] = [];
|
||||
const { ledger, claims } = countingLedger(
|
||||
ledgerWith(["acp_runtime", "control_bridge", "agent_bridge", "staging_lease"]),
|
||||
);
|
||||
|
||||
await settleAcpRun(ledger, cleanTurnCause, baseSteps(order));
|
||||
|
||||
// The sequence claims the ledger exactly once.
|
||||
expect(claims()).toBe(1);
|
||||
// It runs the effect steps in the fixed order; the lease release runs last.
|
||||
expect(order).toEqual([
|
||||
"end_session",
|
||||
"settle_reuse",
|
||||
"stop_transport",
|
||||
"sync_back",
|
||||
"release_staging_lease",
|
||||
]);
|
||||
});
|
||||
|
||||
it("test_every_step_noops_on_empty_slot", async () => {
|
||||
const order: string[] = [];
|
||||
// An empty ledger: no slot holds a resource. Each step reads its slot and
|
||||
// no-ops when the slot is empty.
|
||||
const ledger = ledgerWith([]);
|
||||
const noopFor = (name: string, id: ResourceId) => (slots: SettlementSlots) => {
|
||||
order.push(slots.has(id) ? `${name}:work` : `${name}:noop`);
|
||||
};
|
||||
const steps = baseSteps(order, {
|
||||
endSession: noopFor("end_session", "acp_runtime"),
|
||||
settleReuse: noopFor("settle_reuse", "acp_runtime"),
|
||||
stopTransport: noopFor("stop_transport", "control_bridge"),
|
||||
syncBack: noopFor("sync_back", "staged_runtime"),
|
||||
releaseStagingLease: (slots) => {
|
||||
order.push(slots.has("staging_lease") ? "release_staging_lease:work" : "release_staging_lease:noop");
|
||||
},
|
||||
});
|
||||
|
||||
const report = await settleAcpRun(ledger, cleanTurnCause, steps);
|
||||
|
||||
// Every step saw an empty slot and no-opped.
|
||||
expect(order).toEqual([
|
||||
"end_session:noop",
|
||||
"settle_reuse:noop",
|
||||
"stop_transport:noop",
|
||||
"sync_back:noop",
|
||||
"release_staging_lease:noop",
|
||||
]);
|
||||
// An empty ledger yields an empty disposition report.
|
||||
expect(report.records).toEqual([]);
|
||||
});
|
||||
|
||||
it("test_transferred_runtime_is_never_closed_and_discarded_entry_is_finalized", async () => {
|
||||
const order: string[] = [];
|
||||
const ledger = ledgerWith(["acp_runtime", "staging_lease"], {
|
||||
acp_runtime: { runtime: {}, sessionHandle: {}, childProcessPid: null },
|
||||
});
|
||||
const steps = baseSteps(order, {
|
||||
// A clean host turn with no live run-scoped credential is save-eligible.
|
||||
reuseCandidate: (): ReuseCandidateInput => ({
|
||||
kind: "host",
|
||||
causePermitsSave: true,
|
||||
liveRunScopedCredentials: [],
|
||||
}),
|
||||
endSession: (_slots, decision) => {
|
||||
// The transferred runtime is never closed.
|
||||
order.push(decision.kind === "save" ? "kept" : "closed");
|
||||
},
|
||||
settleReuse: (_slots, decision) => {
|
||||
order.push(decision.kind === "save" ? "transferred" : "discarded");
|
||||
},
|
||||
});
|
||||
|
||||
const report = await settleAcpRun(ledger, cleanTurnCause, steps);
|
||||
|
||||
// The runtime transferred; it was never closed.
|
||||
expect(order).toContain("kept");
|
||||
expect(order).not.toContain("closed");
|
||||
expect(order).toContain("transferred");
|
||||
// The report marks the transferred runtime `transferred` and the discarded
|
||||
// staging lease `finalized`.
|
||||
expect(report.records).toEqual([
|
||||
{ id: "acp_runtime", disposition: "transferred" },
|
||||
{ id: "staging_lease", disposition: "finalized" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("test_step_error_is_recorded_and_later_steps_still_run", async () => {
|
||||
const order: string[] = [];
|
||||
const recorded: string[] = [];
|
||||
const boom = new Error("end_session boom");
|
||||
const steps = baseSteps(order, {
|
||||
endSession: () => {
|
||||
throw boom;
|
||||
},
|
||||
recordError: (step, error) => {
|
||||
recorded.push(step);
|
||||
expect(error).toBe(boom);
|
||||
},
|
||||
});
|
||||
|
||||
await settleAcpRun(ledgerWith(["acp_runtime", "staging_lease"]), cleanTurnCause, steps);
|
||||
|
||||
// The failed step is recorded...
|
||||
expect(recorded).toContain("end_session");
|
||||
// ...and every later step still runs.
|
||||
expect(order).toEqual([
|
||||
"settle_reuse",
|
||||
"stop_transport",
|
||||
"sync_back",
|
||||
"release_staging_lease",
|
||||
]);
|
||||
});
|
||||
|
||||
it("test_staging_lease_release_runs_in_finally", async () => {
|
||||
const order: string[] = [];
|
||||
const recorded: string[] = [];
|
||||
const steps = baseSteps(order, {
|
||||
syncBack: () => {
|
||||
throw new Error("sync_back boom");
|
||||
},
|
||||
recordError: (step) => {
|
||||
recorded.push(step);
|
||||
},
|
||||
});
|
||||
|
||||
await settleAcpRun(ledgerWith(["staging_lease"]), cleanTurnCause, steps);
|
||||
|
||||
// The sync-back failure is recorded, and the lease release still runs.
|
||||
expect(recorded).toContain("sync_back");
|
||||
expect(order).toContain("release_staging_lease");
|
||||
});
|
||||
|
||||
it("test_two_compatible_runs_do_not_share_run_scoped_credentials", async () => {
|
||||
// Two compatible runs each own their ledger, bridge token, and run API key.
|
||||
// Run B must never observe run A's run-scoped values.
|
||||
const observed: Array<{ run: string; token: unknown; credentials: readonly string[] }> = [];
|
||||
const settleRun = async (run: string, token: string, credential: string) => {
|
||||
const ledger = ledgerWith(["acp_runtime", "control_bridge"], {
|
||||
control_bridge: { token },
|
||||
});
|
||||
const steps = baseSteps([], {
|
||||
reuseCandidate: (): ReuseCandidateInput => ({
|
||||
kind: "host",
|
||||
causePermitsSave: true,
|
||||
liveRunScopedCredentials: [credential],
|
||||
}),
|
||||
stopTransport: (slots) => {
|
||||
const bridge = slots.get("control_bridge") as { token: unknown } | undefined;
|
||||
observed.push({ run, token: bridge?.token, credentials: [credential] });
|
||||
},
|
||||
});
|
||||
return settleAcpRun(ledger, cleanTurnCause, steps);
|
||||
};
|
||||
|
||||
await settleRun("A", "token-A", "run-api-key-A");
|
||||
await settleRun("B", "token-B", "run-api-key-B");
|
||||
|
||||
const runA = observed.find((o) => o.run === "A");
|
||||
const runB = observed.find((o) => o.run === "B");
|
||||
// Run B observed only its own bridge token and credential, never run A's.
|
||||
expect(runB?.token).toBe("token-B");
|
||||
expect(runB?.token).not.toBe(runA?.token);
|
||||
expect(runB?.credentials).not.toContain("run-api-key-A");
|
||||
});
|
||||
|
||||
it("test_no_run_scoped_credential_appears_in_reuse_payload_or_telemetry", async () => {
|
||||
const runScopedCredential = "run-api-key-secret";
|
||||
let savedPayload: Record<string, unknown> | null = null;
|
||||
// A save-eligible sandbox candidate: the staged-files payload carries no
|
||||
// credential, and the bridge tokens already died with the bridges, so the
|
||||
// live run-scoped credential list is empty.
|
||||
const ledger = ledgerWith(["staged_runtime", "staging_lease"], {
|
||||
staged_runtime: { stagedRuntime: { files: ["a", "b"] } },
|
||||
});
|
||||
const steps = baseSteps([], {
|
||||
reuseCandidate: (): ReuseCandidateInput => ({
|
||||
kind: "sandbox",
|
||||
causePermitsSave: true,
|
||||
liveRunScopedCredentials: [],
|
||||
}),
|
||||
settleReuse: (slots, decision) => {
|
||||
if (decision.kind === "save") {
|
||||
const staged = slots.get("staged_runtime") as unknown as {
|
||||
stagedRuntime: Record<string, unknown>;
|
||||
};
|
||||
savedPayload = staged.stagedRuntime;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const report = await settleAcpRun(ledger, cleanTurnCause, steps);
|
||||
|
||||
// The saved reuse payload carries no run-scoped credential.
|
||||
expect(savedPayload).not.toBeNull();
|
||||
expect(JSON.stringify(savedPayload)).not.toContain(runScopedCredential);
|
||||
// The disposition report (telemetry) carries no run-scoped credential.
|
||||
expect(JSON.stringify(report)).not.toContain(runScopedCredential);
|
||||
// The staged files transferred.
|
||||
expect(report.records).toContainEqual({ id: "staged_runtime", disposition: "transferred" });
|
||||
});
|
||||
|
||||
it("test_warm_save_requires_credential_gate_pass_else_close_and_relaunch", async () => {
|
||||
const runWithCredentials = async (liveRunScopedCredentials: readonly string[]) => {
|
||||
const order: string[] = [];
|
||||
const ledger = ledgerWith(["acp_runtime"]);
|
||||
const steps = baseSteps(order, {
|
||||
reuseCandidate: (): ReuseCandidateInput => ({
|
||||
kind: "host",
|
||||
causePermitsSave: true,
|
||||
liveRunScopedCredentials,
|
||||
}),
|
||||
endSession: (_slots, decision) => {
|
||||
order.push(decision.kind === "save" ? "kept" : "closed");
|
||||
},
|
||||
});
|
||||
const report = await settleAcpRun(ledger, cleanTurnCause, steps);
|
||||
return { order, report };
|
||||
};
|
||||
|
||||
// A live run-scoped credential fails the gate: close-and-relaunch, finalized.
|
||||
const gated = await runWithCredentials(["run-api-key"]);
|
||||
expect(gated.order).toContain("closed");
|
||||
expect(gated.report.records).toContainEqual({ id: "acp_runtime", disposition: "finalized" });
|
||||
|
||||
// No live run-scoped credential passes the gate: the runtime transfers.
|
||||
const cleared = await runWithCredentials([]);
|
||||
expect(cleared.order).toContain("kept");
|
||||
expect(cleared.report.records).toContainEqual({ id: "acp_runtime", disposition: "transferred" });
|
||||
});
|
||||
|
||||
it("decideReuse blocks a save when the Amendment B credential gate fails", () => {
|
||||
// A direct unit check on the pure decision, so the gate is legible on its own.
|
||||
const base: ReuseCandidateInput = { kind: "host", causePermitsSave: true, liveRunScopedCredentials: [] };
|
||||
expect(decideReuse(base)).toEqual({ kind: "save", savedId: "acp_runtime" } satisfies ReuseDecision);
|
||||
expect(decideReuse({ ...base, liveRunScopedCredentials: ["k"] }).kind).toBe("discard");
|
||||
expect(decideReuse({ ...base, causePermitsSave: false }).kind).toBe("discard");
|
||||
expect(decideReuse(null).kind).toBe("discard");
|
||||
expect(decideReuse({ ...base, kind: "sandbox" })).toEqual({
|
||||
kind: "save",
|
||||
savedId: "staged_runtime",
|
||||
} satisfies ReuseDecision);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// The settlement sequence.
|
||||
//
|
||||
// `settleAcpRun(ledger, cause, steps)` is the one cleanup owner for every site
|
||||
// and every settled path. The one exception is the startup rollback, which the
|
||||
// coordinator owns (Phase 20).
|
||||
//
|
||||
// The sequence claims the ledger once, makes the pure reuse decision, then runs
|
||||
// the ordered steps:
|
||||
//
|
||||
// 1. endSession — close every runtime the decision did not transfer, and
|
||||
// drop the warm entry when it closes.
|
||||
// 2. settleReuse — perform the decision: a save transfers the candidate to the
|
||||
// site store (which arms the per-entry idle timer); everything
|
||||
// else discards.
|
||||
// 3. stopTransport — stop both bridges in one `allSettled`.
|
||||
// 4. syncBack — the site sync-back.
|
||||
// 5. releaseStagingLease — runs in a `finally`, so an earlier step fault never
|
||||
// strands the lease.
|
||||
//
|
||||
// The Phase 3 error policy governs every step: a step records its error and the
|
||||
// later steps still run. Every step no-ops on an empty slot.
|
||||
//
|
||||
// Amendment B — the credential gate. A runtime is save-eligible only when no
|
||||
// run-scoped credential issued to it remains valid. The decision is pure and
|
||||
// runs before any close. Settlement stops both bridges before a sandbox-lane
|
||||
// transfer, so the bridge tokens die with the bridges. The run API key is not
|
||||
// invalidated on run end (grounded server-side), so the host-lane warm save
|
||||
// discards instead of transfers (close-and-relaunch) until a credential-rebind
|
||||
// protocol exists. The sandbox staged-files reuse is unaffected: its payload
|
||||
// carries no credentials.
|
||||
|
||||
import type {
|
||||
AcquiredRunResources,
|
||||
ResourceDisposition,
|
||||
ResourceId,
|
||||
RunResourcePayloads,
|
||||
RunSiteKind,
|
||||
SettledResourceEntry,
|
||||
SettlementCause,
|
||||
} from "./run-contracts.js";
|
||||
|
||||
/** The save-versus-discard decision the settlement makes before any close. */
|
||||
export type ReuseDecision =
|
||||
| { readonly kind: "save"; readonly savedId: ResourceId }
|
||||
| { readonly kind: "discard"; readonly reason: string };
|
||||
|
||||
/**
|
||||
* The inputs the reuse decision reads. The site derives the candidate from the
|
||||
* settled slots. A null candidate means the run holds nothing to save.
|
||||
*/
|
||||
export interface ReuseCandidateInput {
|
||||
/** The site kind. "host" saves the runtime; "sandbox" saves the staged files. */
|
||||
readonly kind: RunSiteKind;
|
||||
/** True when the settlement cause permits a save (a clean persistent turn). */
|
||||
readonly causePermitsSave: boolean;
|
||||
/**
|
||||
* Amendment B: the run-scoped credentials the candidate would still carry into
|
||||
* the store. A save is eligible only when this list is empty — no run-scoped
|
||||
* credential issued to the runtime remains valid.
|
||||
*/
|
||||
readonly liveRunScopedCredentials: readonly string[];
|
||||
}
|
||||
|
||||
/** One resource's final disposition, as the settlement records it. */
|
||||
export interface ResourceDispositionRecord {
|
||||
readonly id: ResourceId;
|
||||
readonly disposition: ResourceDisposition;
|
||||
}
|
||||
|
||||
/** The per-resource disposition report the settlement produces at the end. */
|
||||
export interface SettlementDispositionReport {
|
||||
readonly records: readonly ResourceDispositionRecord[];
|
||||
}
|
||||
|
||||
/** The settled slots view a step reads. `get` returns undefined for an empty slot. */
|
||||
export interface SettlementSlots {
|
||||
get<Id extends ResourceId>(id: Id): RunResourcePayloads[Id] | undefined;
|
||||
has(id: ResourceId): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The effectful steps the sequence runs. The engine implements each step over
|
||||
* its own run state. Each step no-ops on an empty slot. A step must not throw
|
||||
* for the sequence to record its error and continue; a throw is caught and
|
||||
* recorded under the Phase 3 error policy.
|
||||
*/
|
||||
export interface SettlementSteps {
|
||||
/**
|
||||
* Derive the reuse candidate from the settled slots and the cause, or null
|
||||
* when the run holds nothing to save.
|
||||
*/
|
||||
reuseCandidate(slots: SettlementSlots, cause: SettlementCause | null): ReuseCandidateInput | null;
|
||||
/** Close every runtime the decision did not transfer; drop the warm entry on close. */
|
||||
endSession(slots: SettlementSlots, decision: ReuseDecision): Promise<void> | void;
|
||||
/** Perform the decision: a save transfers to the site store; everything else discards. */
|
||||
settleReuse(slots: SettlementSlots, decision: ReuseDecision): Promise<void> | void;
|
||||
/** Stop both bridges in one `allSettled`. */
|
||||
stopTransport(slots: SettlementSlots): Promise<void> | void;
|
||||
/** The site sync-back. */
|
||||
syncBack(slots: SettlementSlots): Promise<void> | void;
|
||||
/** Release the staging lease. Runs in a `finally`. */
|
||||
releaseStagingLease(slots: SettlementSlots): void;
|
||||
/** Record a per-step error under the Phase 3 error policy. */
|
||||
recordError(step: string, error: unknown): Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure reuse decision. A save is eligible only when a candidate exists, the
|
||||
* cause permits a save, and Amendment B holds: no run-scoped credential issued
|
||||
* to the candidate remains valid. Everything else discards, with a reason.
|
||||
*/
|
||||
export function decideReuse(candidate: ReuseCandidateInput | null): ReuseDecision {
|
||||
if (!candidate) {
|
||||
return { kind: "discard", reason: "no reuse candidate" };
|
||||
}
|
||||
if (!candidate.causePermitsSave) {
|
||||
return { kind: "discard", reason: "settlement cause forbids save" };
|
||||
}
|
||||
if (candidate.liveRunScopedCredentials.length > 0) {
|
||||
// Amendment B credential gate: a live run-scoped credential blocks the save.
|
||||
return { kind: "discard", reason: "run-scoped credential still valid" };
|
||||
}
|
||||
const savedId: ResourceId = candidate.kind === "host" ? "acp_runtime" : "staged_runtime";
|
||||
return { kind: "save", savedId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the run's resources. Claims the ledger once, makes the reuse decision,
|
||||
* runs the ordered steps under the Phase 3 error policy, releases the staging
|
||||
* lease in a `finally`, and returns the final per-resource disposition report.
|
||||
*/
|
||||
export async function settleAcpRun(
|
||||
ledger: AcquiredRunResources,
|
||||
cause: SettlementCause | null,
|
||||
steps: SettlementSteps,
|
||||
): Promise<SettlementDispositionReport> {
|
||||
// Claim the ledger once. Every later step reads the claimed slots.
|
||||
const consumed = ledger.takeForSettlement();
|
||||
const entries = consumed.entries();
|
||||
const slots = makeSlots(entries);
|
||||
|
||||
// The reuse decision is pure and runs before any close.
|
||||
const candidate = steps.reuseCandidate(slots, cause);
|
||||
const decision = decideReuse(candidate);
|
||||
|
||||
const runStep = async (name: string, run: () => Promise<void> | void): Promise<void> => {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
// The Phase 3 error policy: record the step error and let later steps run.
|
||||
await steps.recordError(name, error);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await runStep("end_session", () => steps.endSession(slots, decision));
|
||||
await runStep("settle_reuse", () => steps.settleReuse(slots, decision));
|
||||
await runStep("stop_transport", () => steps.stopTransport(slots));
|
||||
await runStep("sync_back", () => steps.syncBack(slots));
|
||||
} finally {
|
||||
// The staging lease release runs in a `finally`, so an earlier step fault
|
||||
// never strands the lease.
|
||||
try {
|
||||
steps.releaseStagingLease(slots);
|
||||
} catch (error) {
|
||||
await steps.recordError("release_staging_lease", error);
|
||||
}
|
||||
}
|
||||
|
||||
return buildReport(entries, decision);
|
||||
}
|
||||
|
||||
/** Build the settled slots view from the claimed entries. */
|
||||
function makeSlots(entries: readonly SettledResourceEntry[]): SettlementSlots {
|
||||
const byId = new Map<ResourceId, SettledResourceEntry>();
|
||||
for (const entry of entries) byId.set(entry.id, entry);
|
||||
return {
|
||||
get<Id extends ResourceId>(id: Id): RunResourcePayloads[Id] | undefined {
|
||||
return byId.get(id)?.payload as RunResourcePayloads[Id] | undefined;
|
||||
},
|
||||
has(id: ResourceId): boolean {
|
||||
return byId.has(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final per-resource disposition report. The claim-time value the
|
||||
* ledger stamps ("transferred") is not the final report; the settlement decides
|
||||
* it here. A saved resource is `transferred`; every other resource is
|
||||
* `finalized`.
|
||||
*/
|
||||
function buildReport(
|
||||
entries: readonly SettledResourceEntry[],
|
||||
decision: ReuseDecision,
|
||||
): SettlementDispositionReport {
|
||||
const savedId = decision.kind === "save" ? decision.savedId : null;
|
||||
return {
|
||||
records: entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
disposition: entry.id === savedId ? ("transferred" as const) : ("finalized" as const),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpRuntimeOptions } from "acpx/runtime";
|
||||
import type { AdapterExecutionContext, AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
startAdapterExecutionTargetProcessSessionBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
// Wrap the staging seam + both sandbox bridges in call-recording spies that
|
||||
// still delegate to the real implementations. This copies the execute.test.ts
|
||||
// harness verbatim so a startup test asserts the exact staging args and bridge
|
||||
// hand-off the engine threads without changing any real behavior.
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
|
||||
return {
|
||||
...actual,
|
||||
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
|
||||
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
|
||||
};
|
||||
});
|
||||
import { createAcpxEngineExecutor, type AcpxEngineExecutorOptions } from "./execute.js";
|
||||
import { runChildProcess } from "../server-utils.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function makeTempRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
// A remote run stages a process-session bridge whose detached event writer can
|
||||
// still be flushing a trailing event file into `.../process-sessions/<id>/events`
|
||||
// when the run's own best-effort `client.remove(sessionDir)` (which production
|
||||
// catch-wraps) has already returned. Under CI load that trailing write can land
|
||||
// between this recursive delete's directory snapshot and its `rmdir`, surfacing as
|
||||
// `ENOTEMPTY`. `maxRetries`/`retryDelay` make the cleanup ride out that window the
|
||||
// same way production tolerates it, instead of failing the just-passed test.
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) =>
|
||||
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
async function pathExists(candidate: string): Promise<boolean> {
|
||||
return fs.access(candidate).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
void pathExists;
|
||||
|
||||
function createLocalSandboxRunner(
|
||||
onExecute?: (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
}) => void,
|
||||
) {
|
||||
let counter = 0;
|
||||
return {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}) => {
|
||||
counter += 1;
|
||||
onExecute?.(input);
|
||||
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
||||
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
env: input.env ?? {},
|
||||
stdin: input.stdin,
|
||||
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
||||
graceSec: 5,
|
||||
onLog: input.onLog ?? (async () => {}),
|
||||
onSpawn: input.onSpawn
|
||||
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntime(
|
||||
onSetConfigOption?: (input: { key: string; value: string }) => void,
|
||||
onEnsureSession?: (input: Record<string, unknown>) => void,
|
||||
) {
|
||||
return {
|
||||
ensureSession: async (input: Record<string, unknown>) => {
|
||||
onEnsureSession?.(input);
|
||||
return ({
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
});
|
||||
},
|
||||
startTurn: () => ({
|
||||
events: (async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
setConfigOption: async (input: { key: string; value: string }) => {
|
||||
onSetConfigOption?.(input);
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
async function runExecutor(
|
||||
config: Record<string, unknown>,
|
||||
options: {
|
||||
context?: Record<string, unknown>;
|
||||
executionTransport?: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
executionTarget?: Record<string, unknown>;
|
||||
runtimeMcp?: AdapterRuntimeMcpAccess;
|
||||
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
|
||||
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
|
||||
} = {},
|
||||
) {
|
||||
const runtimeOptions: Record<string, unknown>[] = [];
|
||||
const configOptions: Array<{ key: string; value: string }> = [];
|
||||
const sessionInputs: Record<string, unknown>[] = [];
|
||||
const meta: Record<string, unknown>[] = [];
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
||||
const execute = createAcpxEngineExecutor({
|
||||
...(options.prepareRemoteManagedHome
|
||||
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
|
||||
: {}),
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions.push(options as unknown as Record<string, unknown>);
|
||||
return buildRuntime(
|
||||
({ key, value }) => configOptions.push({ key, value }),
|
||||
(input) => sessionInputs.push(input),
|
||||
) as never;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
},
|
||||
runtime: {},
|
||||
config,
|
||||
context: options.context ?? {},
|
||||
executionTransport: options.executionTransport,
|
||||
authToken: options.authToken,
|
||||
executionTarget: options.executionTarget,
|
||||
runtimeMcp: options.runtimeMcp,
|
||||
startupTraceContext: options.startupTraceContext,
|
||||
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
||||
logs.push({ stream, text });
|
||||
},
|
||||
onMeta: async (payload: unknown) => {
|
||||
meta.push(payload as Record<string, unknown>);
|
||||
},
|
||||
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
||||
events.push(event);
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result };
|
||||
}
|
||||
|
||||
// The staging-seam describe helper from execute.test.ts (~:2152). It builds a
|
||||
// runner-backed remote target: the local runner extracts the staged tar into
|
||||
// `remoteCwd`, so the run really ships the HOST worktree into the sandbox.
|
||||
async function setupRemoteSandbox() {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const localCwd = path.join(root, "worktree");
|
||||
const remoteCwd = path.join(root, "remote-workspace");
|
||||
await fs.mkdir(localCwd, { recursive: true });
|
||||
await fs.mkdir(remoteCwd, { recursive: true });
|
||||
// A file present only in the HOST worktree proves the workspace is shipped
|
||||
// into the sandbox: the local runner extracts the staged tar into remoteCwd.
|
||||
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
||||
const runner = createLocalSandboxRunner();
|
||||
const executionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "fake-plugin",
|
||||
remoteCwd,
|
||||
runner,
|
||||
};
|
||||
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
||||
}
|
||||
|
||||
// Read the `configFingerprint` off a settled run result.
|
||||
function fpOf(result: { sessionParams?: unknown }): string | undefined {
|
||||
return (result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint;
|
||||
}
|
||||
|
||||
const okHandle = {
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
};
|
||||
|
||||
function completedTurn() {
|
||||
return {
|
||||
events: (async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ACPX engine startup characterization", () => {
|
||||
// Item 1 + 8: the remote launch env and its finalization point.
|
||||
describe("remote launch environment values", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("mints the bridge launch env into the process-session command payload", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
// Decode the process-session LAUNCH payload (the base64 command blob). The
|
||||
// in-sandbox process env rides there, not in the exec's own `env`.
|
||||
let launchPayload: Record<string, unknown> | null = null;
|
||||
(executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => {
|
||||
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
|
||||
const script = input.args?.[1] ?? "";
|
||||
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
|
||||
if (match) {
|
||||
launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await runExecutor(
|
||||
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
{ authToken: "real-run-jwt", executionTarget },
|
||||
);
|
||||
|
||||
// The launch payload carries the MERGED paperclip bridge env: the queue
|
||||
// transport mode, a loopback bridge base URL, and a minted bridge token.
|
||||
const payloadEnv = ((launchPayload as Record<string, unknown> | null)?.env ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(payloadEnv).toMatchObject({ PAPERCLIP_API_BRIDGE_MODE: "queue_v1" });
|
||||
expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
// The minted bridge token is present and is NOT the host run JWT.
|
||||
expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
|
||||
expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
|
||||
});
|
||||
|
||||
it("finalizes the launch env at the bridge merge: the process env carries the merged bridge values", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
// Capture the launch payload AND the bridge-channel exec's own `env`.
|
||||
let launchPayload: Record<string, unknown> | null = null;
|
||||
let bridgeExecEnv: Record<string, string> | undefined;
|
||||
(executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => {
|
||||
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
|
||||
bridgeExecEnv = input.env;
|
||||
const script = input.args?.[1] ?? "";
|
||||
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
|
||||
if (match) {
|
||||
launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await runExecutor(
|
||||
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
{ authToken: "real-run-jwt", executionTarget },
|
||||
);
|
||||
|
||||
// The launch payload is the finalized carrier of the process env. It already
|
||||
// holds the merged bridge values at the point the run emits it, so no later
|
||||
// write mutates the process env after the bridge merge.
|
||||
const payloadEnv = ((launchPayload as Record<string, unknown> | null)?.env ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(payloadEnv.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
|
||||
// The bridge-channel exec's OWN env is the sandbox transport channel, not the
|
||||
// agent process env: it does not carry the minted agent bridge key. This pins
|
||||
// that the merged agent env lives only in the finalized launch payload.
|
||||
expect(bridgeExecEnv?.PAPERCLIP_SANDBOX_EXEC_CHANNEL).toBe("bridge");
|
||||
expect(bridgeExecEnv?.PAPERCLIP_API_KEY).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Item 2: the 17 fingerprint fields folded into `configFingerprint`, and the
|
||||
// outer session key form that embeds it.
|
||||
describe("session fingerprint and session key", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("forms the session key as paperclip:company:agent:taskKey:fingerprint and embeds the fingerprint", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const { result } = await runExecutor({
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir: path.join(root, "state"),
|
||||
cwd: path.join(root, "workspace"),
|
||||
});
|
||||
|
||||
const fp = fpOf(result);
|
||||
expect(fp).toBeTypeOf("string");
|
||||
expect(fp).toBeTruthy();
|
||||
// No taskId/issueId/workspaceId in the default context, so taskKey is "default".
|
||||
const sessionKey = (result.sessionParams as { sessionKey?: string }).sessionKey;
|
||||
expect(sessionKey).toBe(`paperclip:company-1:agent-1:default:${fp}`);
|
||||
});
|
||||
|
||||
it("keeps the fingerprint stable across two identical runs and a same-config new wake", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const baseConfig = {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir: path.join(root, "state"),
|
||||
cwd: path.join(root, "workspace"),
|
||||
};
|
||||
const first = await runExecutor(baseConfig, {
|
||||
context: { taskId: "issue-1", wakeReason: "issue_assigned" },
|
||||
});
|
||||
const identical = await runExecutor(baseConfig, {
|
||||
context: { taskId: "issue-1", wakeReason: "issue_assigned" },
|
||||
});
|
||||
// A fresh heartbeat with a different wake reason but the same config env.
|
||||
const newWake = await runExecutor(baseConfig, {
|
||||
context: { taskId: "issue-1", wakeReason: "comment", wakeCommentId: "c-9" },
|
||||
});
|
||||
|
||||
expect(fpOf(first.result)).toBeTruthy();
|
||||
expect(fpOf(identical.result)).toBe(fpOf(first.result));
|
||||
// Per-wake PAPERCLIP_* churn does not reset the session fingerprint.
|
||||
expect(fpOf(newWake.result)).toBe(fpOf(first.result));
|
||||
});
|
||||
|
||||
it("busts the fingerprint when any representative folded dimension changes", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cwd = path.join(root, "workspace");
|
||||
const stateDir = path.join(root, "state");
|
||||
const context = { context: { taskId: "issue-1", wakeReason: "issue_assigned" } };
|
||||
const base = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd };
|
||||
|
||||
const baseFp = fpOf((await runExecutor(base, context)).result);
|
||||
expect(baseFp).toBeTruthy();
|
||||
|
||||
// Each edit changes exactly one folded dimension and must bust the fingerprint.
|
||||
// agentCommand.
|
||||
expect(fpOf((await runExecutor({ ...base, agentCommand: "node ./other-acp.js" }, context)).result)).not.toBe(baseFp);
|
||||
// cwd.
|
||||
expect(fpOf((await runExecutor({ ...base, cwd: path.join(root, "other-cwd") }, context)).result)).not.toBe(baseFp);
|
||||
// requestedModel.
|
||||
expect(fpOf((await runExecutor({ ...base, model: "some-model" }, context)).result)).not.toBe(baseFp);
|
||||
// requestedThinkingEffort.
|
||||
expect(fpOf((await runExecutor({ ...base, thinkingEffort: "high" }, context)).result)).not.toBe(baseFp);
|
||||
// mode.
|
||||
expect(fpOf((await runExecutor({ ...base, mode: "oneshot" }, context)).result)).not.toBe(baseFp);
|
||||
// adapterEnvHash (a resolved adapter env value).
|
||||
expect(fpOf((await runExecutor({ ...base, env: { FOO: "bar" } }, context)).result)).not.toBe(baseFp);
|
||||
// mcpServers identity (injected runtime MCP set).
|
||||
expect(
|
||||
fpOf(
|
||||
(
|
||||
await runExecutor(base, {
|
||||
...context,
|
||||
runtimeMcp: {
|
||||
getServers: () => [
|
||||
{ name: "github", url: "https://x.test/mcp", connectionId: "c-1", token: "t-1" },
|
||||
],
|
||||
},
|
||||
})
|
||||
).result,
|
||||
),
|
||||
).not.toBe(baseFp);
|
||||
// secretManifestHash.
|
||||
expect(
|
||||
fpOf(
|
||||
(
|
||||
await runExecutor(base, {
|
||||
...context,
|
||||
context: {
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_assigned",
|
||||
paperclipSecrets: {
|
||||
manifest: [
|
||||
{
|
||||
configPath: "env.API_TOKEN",
|
||||
envKey: "API_TOKEN",
|
||||
secretId: "secret-1",
|
||||
bindingId: "binding-1",
|
||||
secretKey: "api-token",
|
||||
version: 1,
|
||||
provider: "local_encrypted",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
).result,
|
||||
),
|
||||
).not.toBe(baseFp);
|
||||
// additionalSourcesIdentity (referenced-project set).
|
||||
expect(
|
||||
fpOf(
|
||||
(
|
||||
await runExecutor(base, {
|
||||
...context,
|
||||
context: {
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_assigned",
|
||||
paperclipWorkspace: {
|
||||
cwd,
|
||||
realization: {
|
||||
additional: [
|
||||
{
|
||||
path: "/host/project-a",
|
||||
projectId: "a",
|
||||
projectWorkspaceId: "ws-a",
|
||||
repoUrl: "https://example.test/a.git",
|
||||
repoRef: "ref-a-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).result,
|
||||
),
|
||||
).not.toBe(baseFp);
|
||||
|
||||
// fastMode folds only for codex, so pin it with a codex-vs-codex pair.
|
||||
const codexBase = { agent: "codex", agentCommand: "node ./fake-acp.js", stateDir, cwd };
|
||||
const codexFp = fpOf((await runExecutor(codexBase, context)).result);
|
||||
expect(fpOf((await runExecutor({ ...codexBase, fastMode: true }, context)).result)).not.toBe(codexFp);
|
||||
});
|
||||
});
|
||||
|
||||
// Item 3: the staging seam call, its arguments, and its order (workspace then
|
||||
// assets, serial). Modeled on the PR-1 staging-seam tests.
|
||||
describe("staging seam calls, arguments, and order", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("stages the host workspace with no assets, exactly once, before the process launch", async () => {
|
||||
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const { sessionInputs, events } = await runExecutor(
|
||||
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
{ authToken: "real-run-jwt", executionTarget },
|
||||
);
|
||||
|
||||
// The staging seam crossed exactly once.
|
||||
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1);
|
||||
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
|
||||
// The HOST worktree is shipped first; no per-adapter home asset in this lane.
|
||||
expect(stageArgs.workspaceLocalDir).toBe(localCwd);
|
||||
expect(stageArgs.assets ?? []).toEqual([]);
|
||||
expect(stageArgs.installCommand ?? null).toBeNull();
|
||||
expect(stageArgs.target).toMatchObject({ kind: "remote", transport: "sandbox" });
|
||||
|
||||
// The workspace really landed in the sandbox workspace dir.
|
||||
await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi");
|
||||
// A per-step timing event proves the sync ran inside its timed boundary.
|
||||
const stageEvent = events.find(
|
||||
(event) => event.eventType === "run.startup.step" && event.payload?.step === "stage.sync",
|
||||
);
|
||||
expect(stageEvent).toBeTruthy();
|
||||
// And session/new binds to the in-sandbox workspace cwd the seam returned.
|
||||
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
|
||||
});
|
||||
|
||||
it("threads a managed-home asset through the same seam after the workspace", async () => {
|
||||
const { root, stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const managedHomeDir = path.join(root, "managed-home");
|
||||
await fs.mkdir(managedHomeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(managedHomeDir, "config.json"), "{}", "utf8");
|
||||
|
||||
await runExecutor(
|
||||
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
{
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget,
|
||||
prepareRemoteManagedHome: async (input) => {
|
||||
const stagedRuntime = await input.stage([
|
||||
{ key: "home", localDir: managedHomeDir, followSymlinks: true },
|
||||
]);
|
||||
return { stagedRuntime };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// The seam's home asset is threaded through the SAME staging seam, keyed by
|
||||
// the workspace-local dir. Workspace ships first; the asset rides alongside it.
|
||||
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
|
||||
expect(stageArgs.workspaceLocalDir).toBe(localCwd);
|
||||
expect(stageArgs.assets).toEqual([
|
||||
{ key: "home", localDir: managedHomeDir, followSymlinks: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Item 4: the two-bridge overlap and the ACP-initialization ordering.
|
||||
describe("two-bridge overlap and ACP initialization order", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("defers the process bridge env, shares one runtimeRootDir, and runs session/new on the sandbox cwd", async () => {
|
||||
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const { sessionInputs, runtimeOptions } = await runExecutor(
|
||||
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
{ authToken: "real-run-jwt", executionTarget },
|
||||
);
|
||||
|
||||
// The process-session bridge receives its launch env as a DEFERRED thunk, the
|
||||
// seam that lets its env-independent setup overlap the paperclip bridge start.
|
||||
const processArgs = vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mock.calls[0]![0];
|
||||
expect(typeof processArgs.env).toBe("function");
|
||||
|
||||
// Both bridges receive the SAME real (non-null) runtimeRootDir from staging.
|
||||
const paperclipArgs = vi.mocked(startAdapterExecutionTargetPaperclipBridge).mock.calls[0]![0];
|
||||
expect(paperclipArgs.runtimeRootDir).toBeTruthy();
|
||||
expect(String(paperclipArgs.runtimeRootDir)).toContain(".paperclip-runtime");
|
||||
expect(processArgs.runtimeRootDir).toBe(paperclipArgs.runtimeRootDir);
|
||||
|
||||
// The ACP runtime + session/new both bind to the in-sandbox workspace cwd,
|
||||
// which the run resolves only after the bridges bring the sandbox up.
|
||||
expect(runtimeOptions[0]?.cwd).toBe(remoteCwd);
|
||||
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
|
||||
expect(sessionInputs[0]?.cwd).not.toBe(localCwd);
|
||||
});
|
||||
});
|
||||
|
||||
// Item 5 + 6: every startup exit path, its result phase, and the cleanup-call
|
||||
// set (bridges stop / lease releases / runtime closes).
|
||||
describe("startup exit paths and cleanup", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("create_runtime failure: settles an error result, stops both bridges, releases the lease", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const paperclipStop = vi.fn(async () => {});
|
||||
const processStop = vi.fn(async () => {});
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(
|
||||
async () => ({ env: {}, stop: paperclipStop }) as never,
|
||||
);
|
||||
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
||||
async () => ({ agentCommand: null, stop: processStop }) as never,
|
||||
);
|
||||
const stagingLocks = new Map<string, Promise<unknown>>();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
stagingLocks,
|
||||
warmHandles: new Map(),
|
||||
stagedRuntimes: new Map(),
|
||||
createRuntime: () => {
|
||||
throw new Error("createRuntime boom");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-create-fail-remote",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
context: {},
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never);
|
||||
|
||||
// The post-build runtime-creation failure returns a settled error result.
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("create_runtime");
|
||||
// Both live bridges stop exactly once and the per-session lease releases.
|
||||
expect(paperclipStop).toHaveBeenCalledTimes(1);
|
||||
expect(processStop).toHaveBeenCalledTimes(1);
|
||||
expect(stagingLocks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("partial-bridge failure: throws and stops the concurrently-started bridge exactly once", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
const stop = vi.fn(async () => {});
|
||||
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
|
||||
throw new Error("paperclip bridge boom");
|
||||
});
|
||||
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
||||
async () => ({ agentCommand: null, stop }) as never,
|
||||
);
|
||||
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () => buildRuntime() as never,
|
||||
});
|
||||
|
||||
// A partial bridge failure inside buildRuntime is one of the only two throw
|
||||
// paths, so the run rethrows instead of settling a result.
|
||||
await expect(
|
||||
execute({
|
||||
runId: "run-bridge-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
context: {},
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
} as never),
|
||||
).rejects.toThrow("paperclip bridge boom");
|
||||
|
||||
// The concurrently-started process-session bridge was stopped exactly once.
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cold ensure_session throw: ensure_session error, and the runtime closes (the cold-handshake leak is fixed)", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const closeSpy = vi.fn(async () => {});
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => {
|
||||
throw new Error("ensureSession boom");
|
||||
},
|
||||
startTurn: () => completedTurn(),
|
||||
close: closeSpy,
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "handshake-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("ensure_session");
|
||||
// The runtime enters the ledger the moment it is created, so a cold
|
||||
// `ensureSession` throw (before any handle exists) still closes it through the
|
||||
// settlement `endSession` step. This closes the former cold-handshake leak.
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("missing session handle: ensure_session error and the minimal runtime closes", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const closeSpy = vi.fn(async () => {});
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
// A runtime that returns no session handle drives the missing-handle path.
|
||||
ensureSession: async () => undefined,
|
||||
startTurn: () => completedTurn(),
|
||||
close: closeSpy,
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "missing-handle",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("ensure_session");
|
||||
expect(result.errorCode).toBe("acpx_runtime_error");
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("configure_session failure: configure_session error and the runtime closes", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const closeSpy = vi.fn(async () => {});
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: async () => okHandle,
|
||||
// Gemini model/effort drive a session config option; a throwing setter
|
||||
// fails the configure_session phase after the handshake succeeds.
|
||||
setConfigOption: async () => {
|
||||
throw new Error("setConfigOption boom");
|
||||
},
|
||||
startTurn: () => completedTurn(),
|
||||
close: closeSpy,
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "configure-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: {
|
||||
agent: "gemini",
|
||||
model: "gemini-2.5-pro",
|
||||
thinkingEffort: "high",
|
||||
stateDir: path.join(root, "state"),
|
||||
},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.resultJson?.phase).toBe("configure_session");
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("persistent host failure: the first run closes and relaunches, so a failing second run re-creates and closes", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const startedAt = "2026-01-01T00:00:00.000Z";
|
||||
const closeSpy = vi.fn(async () => {});
|
||||
let created = 0;
|
||||
const warmHandles = new Map();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles,
|
||||
createRuntime: (options) => {
|
||||
created += 1;
|
||||
const opts = options as AcpRuntimeOptions & {
|
||||
onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
};
|
||||
return {
|
||||
ensureSession: async () => {
|
||||
await opts.onAgentSpawn?.({ pid: 4242, startedAt });
|
||||
return okHandle;
|
||||
},
|
||||
startTurn: () => completedTurn(),
|
||||
close: closeSpy,
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
const config = {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
mode: "persistent",
|
||||
warmHandleIdleMs: 60_000,
|
||||
};
|
||||
|
||||
const first = await execute({
|
||||
runId: "warm-1",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
} as never);
|
||||
expect(first.exitCode).toBe(0);
|
||||
// Amendment B: the first clean persistent turn closes and relaunches instead
|
||||
// of warm-saving the runtime, so no warm handle survives.
|
||||
expect(warmHandles.size).toBe(0);
|
||||
expect(created).toBe(1);
|
||||
|
||||
// The second run finds no warm handle, so it re-creates the runtime and fails
|
||||
// while persisting process identity during ensure_session.
|
||||
const second = await execute({
|
||||
runId: "warm-2",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: { sessionParams: first.sessionParams },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {
|
||||
throw new Error("onSpawn boom");
|
||||
},
|
||||
} as never);
|
||||
|
||||
// The second run built runtime #2 (no warm reuse), closed it on the failure,
|
||||
// and reported the ensure_session phase. Both runtimes closed (one per run).
|
||||
expect(created).toBe(2);
|
||||
expect(second.exitCode).toBe(1);
|
||||
expect(second.resultJson?.phase).toBe("ensure_session");
|
||||
expect(closeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(warmHandles.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Item 7: the per-lane resource set.
|
||||
describe("per-lane resource set", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("local lane: crosses no staging seam, starts no bridge, keeps session/new on the host cwd", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const localCwd = path.join(root, "worktree");
|
||||
await fs.mkdir(localCwd, { recursive: true });
|
||||
const { sessionInputs, runtimeOptions } = await runExecutor({
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir: path.join(root, "state"),
|
||||
cwd: localCwd,
|
||||
});
|
||||
|
||||
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(startAdapterExecutionTargetPaperclipBridge)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(startAdapterExecutionTargetProcessSessionBridge)).not.toHaveBeenCalled();
|
||||
expect(sessionInputs[0]?.cwd).toBe(localCwd);
|
||||
expect(runtimeOptions[0]?.cwd).toBe(localCwd);
|
||||
});
|
||||
|
||||
it("persistent host lane: closes and relaunches the handle, so a second run re-creates the runtime", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
let created = 0;
|
||||
const warmHandles = new Map();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles,
|
||||
createRuntime: () => {
|
||||
created += 1;
|
||||
return buildRuntime() as never;
|
||||
},
|
||||
});
|
||||
const config = {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
mode: "persistent",
|
||||
warmHandleIdleMs: 60_000,
|
||||
};
|
||||
const base = {
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onSpawn: async () => {},
|
||||
};
|
||||
|
||||
const first = await execute({ runId: "host-warm-1", runtime: {}, ...base } as never);
|
||||
const second = await execute({
|
||||
runId: "host-warm-2",
|
||||
runtime: { sessionParams: first.sessionParams },
|
||||
...base,
|
||||
} as never);
|
||||
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(second.exitCode).toBe(0);
|
||||
// Amendment B: the host lane never warm-saves a live runtime (the run-minted
|
||||
// API key is never revoked), so the first run closes and relaunches. No warm
|
||||
// handle survives, so the second run re-creates runtime #2.
|
||||
expect(created).toBe(2);
|
||||
expect(warmHandles.size).toBe(0);
|
||||
});
|
||||
|
||||
it("remote process-session lane: does NOT warm-save the handle, so a second run re-creates the runtime", async () => {
|
||||
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
||||
let created = 0;
|
||||
const ensureInputs: Array<Record<string, unknown>> = [];
|
||||
const warmHandles = new Map();
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles,
|
||||
stagedRuntimes: new Map(),
|
||||
stagingLocks: new Map(),
|
||||
createRuntime: () => {
|
||||
created += 1;
|
||||
return buildRuntime(undefined, (input) => ensureInputs.push(input)) as never;
|
||||
},
|
||||
});
|
||||
const base = {
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
config: {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
cwd: localCwd,
|
||||
mode: "persistent",
|
||||
warmHandleIdleMs: 60_000,
|
||||
},
|
||||
context: {},
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
onEvent: async () => {},
|
||||
};
|
||||
|
||||
const first = await execute({ runId: "remote-warm-1", runtime: {}, ...base } as never);
|
||||
const second = await execute({
|
||||
runId: "remote-warm-2",
|
||||
runtime: { sessionParams: first.sessionParams },
|
||||
...base,
|
||||
} as never);
|
||||
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(second.exitCode).toBe(0);
|
||||
// The remote process-session lane never keeps the handle warm, so the second
|
||||
// run re-creates the runtime and runs a fresh handshake instead of reusing one.
|
||||
expect(created).toBe(2);
|
||||
expect(warmHandles.size).toBe(0);
|
||||
expect(ensureInputs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,15 @@ import type { StartupSpan, StartupTraceContext, StartupTracer } from "./startup-
|
||||
import {
|
||||
clampSpanLabel,
|
||||
createRuntimeSpanRunner,
|
||||
emitRunPhaseTiming,
|
||||
emitSkippedStartupStep,
|
||||
getActiveStepContext,
|
||||
measureStartupStep,
|
||||
NOOP_STARTUP_SPAN,
|
||||
NOOP_STARTUP_TRACE_CONTEXT,
|
||||
normalizeProviderFamily,
|
||||
RUN_PHASE_NAMES,
|
||||
RUN_PHASE_TIMING_EVENT_TYPE,
|
||||
runWithoutActiveStep,
|
||||
runWithRuntimeParent,
|
||||
SANDBOX_STARTUP_SPAN_ATTR_PREFIX,
|
||||
@@ -712,3 +715,59 @@ describe("createRuntimeSpanRunner", () => {
|
||||
expect(childStep).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("run phase timing telemetry", () => {
|
||||
it("test_phase_telemetry_fields_are_exactly_phase_duration_outcome", async () => {
|
||||
const events: AdapterRuntimeEvent[] = [];
|
||||
const ctx = { onEvent: async (event: AdapterRuntimeEvent) => void events.push(event) };
|
||||
|
||||
await emitRunPhaseTiming(ctx, "create_runtime", 12, "ok");
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
const event = events[0]!;
|
||||
expect(event.eventType).toBe(RUN_PHASE_TIMING_EVENT_TYPE);
|
||||
// The payload carries exactly the three closed fields and nothing else.
|
||||
expect(Object.keys(event.payload ?? {}).sort()).toEqual(["durationMs", "outcome", "phase"]);
|
||||
expect(event.payload).toEqual({ phase: "create_runtime", durationMs: 12, outcome: "ok" });
|
||||
});
|
||||
|
||||
it("test_phase_telemetry_emits_no_command_path_environment_or_identifier_value", async () => {
|
||||
const events: AdapterRuntimeEvent[] = [];
|
||||
const ctx = { onEvent: async (event: AdapterRuntimeEvent) => void events.push(event) };
|
||||
|
||||
// Every allowlisted phase emits exactly one closed-shape event.
|
||||
for (const phase of RUN_PHASE_NAMES) {
|
||||
await emitRunPhaseTiming(ctx, phase, 5, "ok");
|
||||
}
|
||||
// A phase name outside the closed allowlist emits nothing, so a free-form
|
||||
// label (a command, a path, an environment value, or a raw identifier) can
|
||||
// never reach the stream.
|
||||
await emitRunPhaseTiming(ctx, "/usr/bin/node --flag /secret/path", 5, "ok");
|
||||
await emitRunPhaseTiming(ctx, "PAPERCLIP_API_KEY=abc123", 5, "ok");
|
||||
await emitRunPhaseTiming(ctx, "run-7f3a-agent-42", 5, "ok");
|
||||
|
||||
expect(events).toHaveLength(RUN_PHASE_NAMES.length);
|
||||
for (const event of events) {
|
||||
// The phase field is only ever an allowlisted name.
|
||||
expect(RUN_PHASE_NAMES).toContain(event.payload?.phase);
|
||||
// No field carries a command, a path, an environment value, or an identifier.
|
||||
const serialized = JSON.stringify(event);
|
||||
expect(serialized).not.toContain("/usr/bin/node");
|
||||
expect(serialized).not.toContain("/secret/path");
|
||||
expect(serialized).not.toContain("PAPERCLIP_API_KEY");
|
||||
expect(serialized).not.toContain("abc123");
|
||||
expect(serialized).not.toContain("run-7f3a-agent-42");
|
||||
}
|
||||
});
|
||||
|
||||
it("test_telemetry_failure_does_not_fail_the_run", async () => {
|
||||
const ctx = {
|
||||
onEvent: async () => {
|
||||
throw new Error("telemetry sink boom");
|
||||
},
|
||||
};
|
||||
|
||||
// A throwing telemetry sink never propagates, so the run continues.
|
||||
await expect(emitRunPhaseTiming(ctx, "turn", 9, "failed")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -786,3 +786,72 @@ export async function emitSkippedStartupStep(
|
||||
// Observability must not change startup control flow.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured event emitted once per named run-lifecycle phase, so the duration
|
||||
* and the outcome of each phase land in the run-events stream. It is
|
||||
* observability-only and rides the existing `ctx.onEvent` bridge. The payload is
|
||||
* a closed shape: exactly `phase`, `durationMs`, and `outcome`. The phase name is
|
||||
* from a closed allowlist, so the event never carries a command, an argument, a
|
||||
* path, an environment value, or a raw identifier.
|
||||
*/
|
||||
export const RUN_PHASE_TIMING_EVENT_TYPE = "run.phase.timing";
|
||||
|
||||
/**
|
||||
* The closed set of run-lifecycle phase names. A phase-timing event may name only
|
||||
* one of these. The list is fixed and low-cardinality; it never derives from run
|
||||
* or user data.
|
||||
*/
|
||||
export const RUN_PHASE_NAMES = [
|
||||
"place_workspace",
|
||||
"start_transport",
|
||||
"create_runtime",
|
||||
"ensure_session",
|
||||
"configure_session",
|
||||
"prepare_turn",
|
||||
"turn",
|
||||
"end_session",
|
||||
"settle_reuse",
|
||||
"stop_transport",
|
||||
"sync_back",
|
||||
"release_staging_lease",
|
||||
] as const;
|
||||
|
||||
/** One run-lifecycle phase name from the closed allowlist. */
|
||||
export type RunPhaseName = (typeof RUN_PHASE_NAMES)[number];
|
||||
|
||||
const RUN_PHASE_NAME_SET: ReadonlySet<string> = new Set(RUN_PHASE_NAMES);
|
||||
|
||||
/** The closed outcome set for a phase-timing event. */
|
||||
export type RunPhaseOutcome = "ok" | "failed";
|
||||
|
||||
/**
|
||||
* Emit exactly one `run.phase.timing` event for a run-lifecycle phase. The
|
||||
* payload carries only `phase`, `durationMs`, and `outcome`. It never carries a
|
||||
* command, an argument, a path, an environment value, or a raw identifier. The
|
||||
* phase name must be one member of the closed allowlist; a name outside the
|
||||
* allowlist emits nothing, so a free-form label can never reach the stream. A
|
||||
* negative or a non-finite duration clamps to 0. Every sink call sits inside an
|
||||
* error swallow, so a throwing telemetry sink never fails the run.
|
||||
*/
|
||||
export async function emitRunPhaseTiming(
|
||||
ctx: Pick<AdapterExecutionContext, "onEvent">,
|
||||
phase: string,
|
||||
durationMs: number,
|
||||
outcome: RunPhaseOutcome,
|
||||
): Promise<void> {
|
||||
if (!RUN_PHASE_NAME_SET.has(phase)) return;
|
||||
const safeDuration = Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 0;
|
||||
const event: AdapterRuntimeEvent = {
|
||||
eventType: RUN_PHASE_TIMING_EVENT_TYPE,
|
||||
stream: "system",
|
||||
level: "info",
|
||||
message: `run phase: ${phase} (${safeDuration}ms)`,
|
||||
payload: { phase, durationMs: safeDuration, outcome },
|
||||
};
|
||||
try {
|
||||
await ctx.onEvent?.(event);
|
||||
} catch {
|
||||
// Telemetry never fails the run.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionContext, AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
startAdapterExecutionTargetProcessSessionBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
// Wrap the staging seam + both sandbox bridges in call-recording spies that
|
||||
// still delegate to the real implementations. This mirrors the execute.test.ts
|
||||
// harness so the turn characterization tests share the same mocked module graph.
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
|
||||
return {
|
||||
...actual,
|
||||
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
|
||||
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
|
||||
};
|
||||
});
|
||||
import {
|
||||
createAcpxEngineExecutor,
|
||||
summarizeAcpxTurnUsage,
|
||||
type AcpxEngineExecutorOptions,
|
||||
} from "./execute.js";
|
||||
import { runChildProcess } from "../server-utils.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function makeTempRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
// A remote run stages a process-session bridge whose detached event writer can
|
||||
// still be flushing a trailing event file into `.../process-sessions/<id>/events`
|
||||
// when the run's own best-effort `client.remove(sessionDir)` (which production
|
||||
// catch-wraps) has already returned. Under CI load that trailing write can land
|
||||
// between this recursive delete's directory snapshot and its `rmdir`, surfacing as
|
||||
// `ENOTEMPTY`. `maxRetries`/`retryDelay` make the cleanup ride out that window the
|
||||
// same way production tolerates it, instead of failing the just-passed test.
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) =>
|
||||
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function createLocalSandboxRunner(
|
||||
onExecute?: (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
}) => void,
|
||||
) {
|
||||
let counter = 0;
|
||||
return {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}) => {
|
||||
counter += 1;
|
||||
onExecute?.(input);
|
||||
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
||||
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
env: input.env ?? {},
|
||||
stdin: input.stdin,
|
||||
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
||||
graceSec: 5,
|
||||
onLog: input.onLog ?? (async () => {}),
|
||||
onSpawn: input.onSpawn
|
||||
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntime(
|
||||
onSetConfigOption?: (input: { key: string; value: string }) => void,
|
||||
onEnsureSession?: (input: Record<string, unknown>) => void,
|
||||
) {
|
||||
return {
|
||||
ensureSession: async (input: Record<string, unknown>) => {
|
||||
onEnsureSession?.(input);
|
||||
return ({
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
});
|
||||
},
|
||||
startTurn: () => ({
|
||||
events: (async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
setConfigOption: async (input: { key: string; value: string }) => {
|
||||
onSetConfigOption?.(input);
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
async function runExecutor(
|
||||
config: Record<string, unknown>,
|
||||
options: {
|
||||
context?: Record<string, unknown>;
|
||||
executionTransport?: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
executionTarget?: Record<string, unknown>;
|
||||
runtimeMcp?: AdapterRuntimeMcpAccess;
|
||||
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
|
||||
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
|
||||
} = {},
|
||||
) {
|
||||
const runtimeOptions: Record<string, unknown>[] = [];
|
||||
const configOptions: Array<{ key: string; value: string }> = [];
|
||||
const sessionInputs: Record<string, unknown>[] = [];
|
||||
const meta: Record<string, unknown>[] = [];
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
||||
const execute = createAcpxEngineExecutor({
|
||||
...(options.prepareRemoteManagedHome
|
||||
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
|
||||
: {}),
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions.push(options as unknown as Record<string, unknown>);
|
||||
return buildRuntime(
|
||||
({ key, value }) => configOptions.push({ key, value }),
|
||||
(input) => sessionInputs.push(input),
|
||||
) as never;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
},
|
||||
runtime: {},
|
||||
config,
|
||||
context: options.context ?? {},
|
||||
executionTransport: options.executionTransport,
|
||||
authToken: options.authToken,
|
||||
executionTarget: options.executionTarget,
|
||||
runtimeMcp: options.runtimeMcp,
|
||||
startupTraceContext: options.startupTraceContext,
|
||||
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
||||
logs.push({ stream, text });
|
||||
},
|
||||
onMeta: async (payload: unknown) => {
|
||||
meta.push(payload as Record<string, unknown>);
|
||||
},
|
||||
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
||||
events.push(event);
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result };
|
||||
}
|
||||
|
||||
// A stub ACP runtime that yields a controlled event stream and terminal result.
|
||||
// The tests supply the events/result/cancel behavior; the harness above only
|
||||
// drives always-happy sessions, so the turn tests build their own runtime here.
|
||||
function turnRuntime(input: {
|
||||
events: () => AsyncGenerator<Record<string, unknown>>;
|
||||
result: Promise<Record<string, unknown>>;
|
||||
onStartTurn?: (turnInput: Record<string, unknown>) => void;
|
||||
onCancel?: (reason: string) => void;
|
||||
onClose?: () => void;
|
||||
getStatus?: () => Promise<Record<string, unknown>>;
|
||||
onEnsureSession?: (session: Record<string, unknown>) => void;
|
||||
ensureSession?: (session: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
}) {
|
||||
const runtime: Record<string, unknown> = {
|
||||
ensureSession:
|
||||
input.ensureSession ??
|
||||
(async (session: Record<string, unknown>) => {
|
||||
input.onEnsureSession?.(session);
|
||||
return {
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
};
|
||||
}),
|
||||
startTurn: (turnInput: Record<string, unknown>) => {
|
||||
input.onStartTurn?.(turnInput);
|
||||
return {
|
||||
events: input.events(),
|
||||
result: input.result,
|
||||
cancel: async ({ reason }: { reason: string }) => {
|
||||
input.onCancel?.(reason);
|
||||
},
|
||||
};
|
||||
},
|
||||
close: async () => {
|
||||
input.onClose?.();
|
||||
},
|
||||
};
|
||||
if (input.getStatus) runtime.getStatus = input.getStatus;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
describe("ACPX engine turn characterization", () => {
|
||||
// The stub session handle every ensureSession returns. startTurn must receive
|
||||
// this exact handle object.
|
||||
const SESSION_HANDLE = {
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
};
|
||||
|
||||
it("passes exactly the six turn inputs to startTurn", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
let metaPrompt = "";
|
||||
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
onStartTurn: (turnInput) => {
|
||||
captured = turnInput;
|
||||
},
|
||||
events: async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
},
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-six-inputs",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, timeoutSec: 120 },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async (payload: { prompt?: string }) => {
|
||||
metaPrompt = payload.prompt ?? "";
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const input = captured!;
|
||||
// The handle is the exact session handle ensureSession returned.
|
||||
expect(input.handle).toEqual(SESSION_HANDLE);
|
||||
// The text is the built run prompt, the same string reported to onMeta.
|
||||
expect(input.text).toBe(metaPrompt);
|
||||
expect(typeof input.text).toBe("string");
|
||||
expect((input.text as string).length).toBeGreaterThan(0);
|
||||
// The turn always runs in prompt mode.
|
||||
expect(input.mode).toBe("prompt");
|
||||
// The request id is the run id.
|
||||
expect(input.requestId).toBe("run-six-inputs");
|
||||
// A positive timeoutSec becomes timeoutMs in milliseconds.
|
||||
expect(input.timeoutMs).toBe(120_000);
|
||||
// The abort signal is present and not yet aborted.
|
||||
const signal = input.signal as AbortSignal;
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
expect(signal.aborted).toBe(false);
|
||||
// Exactly the six documented keys are threaded.
|
||||
expect(Object.keys(input).sort()).toEqual(
|
||||
["handle", "mode", "requestId", "signal", "text", "timeoutMs"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("joins text_delta events into the trimmed result summary", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
events: async function* () {
|
||||
yield { type: "text_delta", text: " Hello, ", stream: "output", tag: "agent_message_chunk" };
|
||||
yield { type: "text_delta", text: "world ", stream: "output", tag: "agent_message_chunk" };
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
},
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-summary",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// The summary is the concatenation of the deltas, trimmed.
|
||||
expect(result.summary).toBe("Hello, world");
|
||||
});
|
||||
|
||||
it("falls back to the stop reason for the summary when no text streams", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
events: async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
},
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-no-text",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// With no streamed text, the summary is the terminal stop reason.
|
||||
expect(result.summary).toBe("end_turn");
|
||||
});
|
||||
|
||||
it("pins one event sequence across the log transcript and structured event transports", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
||||
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
events: async function* () {
|
||||
yield { type: "text_delta", text: "streamed hello", stream: "output", tag: "agent_message_chunk" };
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
},
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-transports",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
||||
context: {},
|
||||
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
||||
logs.push({ stream, text });
|
||||
},
|
||||
onMeta: async () => {},
|
||||
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
||||
events.push(event);
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Transport 1 — the stdout transcript carries the text_delta as an acpx record.
|
||||
expect(logs).toContainEqual({
|
||||
stream: "stdout",
|
||||
text: `${JSON.stringify({
|
||||
type: "acpx.text_delta",
|
||||
text: "streamed hello",
|
||||
channel: "output",
|
||||
tag: "agent_message_chunk",
|
||||
})}\n`,
|
||||
});
|
||||
// The joined text also lands in the result summary.
|
||||
expect(result.summary).toBe("streamed hello");
|
||||
// Transport 2 — the structured onEvent stream reflects the run bring-up
|
||||
// sequence as run.startup.step events, including the acp handshake.
|
||||
const steps = events.filter((event) => event.eventType === "run.startup.step");
|
||||
const stepNames = steps.map((event) => String(event.payload?.step));
|
||||
expect(stepNames).toContain("acp.handshake");
|
||||
expect(stepNames).toContain("workspace.resolve");
|
||||
});
|
||||
|
||||
it("aborts a hung turn on the wall-clock timer and cancels with the timeout message", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const cwd = path.join(root, "worktree");
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
|
||||
const cancelReasons: string[] = [];
|
||||
let releaseTurn: (() => void) | null = null;
|
||||
const turnCancelled = new Promise<void>((resolve) => {
|
||||
releaseTurn = resolve;
|
||||
});
|
||||
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
// The stream never yields on its own. Only the wall-clock timer's cancel
|
||||
// unblocks it, simulating a hung run.
|
||||
events: async function* () {
|
||||
await turnCancelled;
|
||||
},
|
||||
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
|
||||
onCancel: (reason) => {
|
||||
cancelReasons.push(reason);
|
||||
releaseTurn?.();
|
||||
},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-timeout",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: 1 },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
const expectedMessage =
|
||||
"Run exceeded the adapter execution timeout (timeoutSec=1, configured via adapterConfig.timeoutSec). " +
|
||||
"Set adapterConfig.timeoutSec to raise it.";
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.signal).toBe("SIGTERM");
|
||||
expect(result.errorCode).toBe("acpx_timeout");
|
||||
expect(result.errorMessage).toBe(expectedMessage);
|
||||
// The cancel ran with the formatted timeout message.
|
||||
expect(cancelReasons).toContain(expectedMessage);
|
||||
}, 15_000);
|
||||
|
||||
it("cancels the turn before closing the runtime when the turn throws", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const order: string[] = [];
|
||||
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
// The event stream throws mid-turn, so the catch path runs teardown.
|
||||
events: async function* () {
|
||||
throw new Error("turn boom");
|
||||
},
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
onCancel: () => order.push("cancel"),
|
||||
onClose: () => order.push("close"),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-throw",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
// A failure after startTurn returned reports phase "turn".
|
||||
expect((result.resultJson as Record<string, unknown>)?.phase).toBe("turn");
|
||||
// Cancel runs before close, in that exact order.
|
||||
expect(order).toEqual(["cancel", "close"]);
|
||||
});
|
||||
|
||||
it("retries the session resume once and never retries the turn", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const cwd = path.join(root, "worktree");
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
const config = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd };
|
||||
|
||||
const ensureInputs: Array<Record<string, unknown>> = [];
|
||||
let startTurnCalls = 0;
|
||||
let resumeLogged = false;
|
||||
|
||||
// A first clean run mints the session params the second run resumes from.
|
||||
const firstExecute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
onEnsureSession: (session) => ensureInputs.push(session),
|
||||
onStartTurn: () => {
|
||||
startTurnCalls += 1;
|
||||
},
|
||||
events: async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
},
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
}) as never,
|
||||
});
|
||||
const first = await firstExecute({
|
||||
runId: "run-resume-a",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config,
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
expect(first.exitCode).toBe(0);
|
||||
|
||||
// The second run resumes. Its first ensureSession (the resume) fails with a
|
||||
// resume-shaped error; the fresh retry (no resumeSessionId) succeeds.
|
||||
const secondExecute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
ensureSession: async (session: Record<string, unknown>) => {
|
||||
ensureInputs.push(session);
|
||||
if (session.resumeSessionId) {
|
||||
throw new Error("resume session not found");
|
||||
}
|
||||
return {
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
};
|
||||
},
|
||||
onStartTurn: () => {
|
||||
startTurnCalls += 1;
|
||||
},
|
||||
events: async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
},
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
}) as never,
|
||||
});
|
||||
const second = await secondExecute({
|
||||
runId: "run-resume-b",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: { sessionParams: (first as { sessionParams?: unknown }).sessionParams },
|
||||
config,
|
||||
context: {},
|
||||
onLog: async (_stream: string, text: string) => {
|
||||
if (text.includes("is unavailable; retrying with a fresh session")) resumeLogged = true;
|
||||
},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(second.exitCode).toBe(0);
|
||||
// The first run's single ensureSession, plus the second run's resume and
|
||||
// fresh retry, make three ensureSession calls total; two on the second run.
|
||||
expect(ensureInputs).toHaveLength(3);
|
||||
expect(ensureInputs[1]?.resumeSessionId).toBe(first.sessionId);
|
||||
expect(ensureInputs[2]?.resumeSessionId).toBeUndefined();
|
||||
// The turn is never retried: one startTurn per run, two across both runs.
|
||||
expect(startTurnCalls).toBe(2);
|
||||
// The engine logged the resume fallback.
|
||||
expect(resumeLogged).toBe(true);
|
||||
// The fresh retry clears the stale session for the caller.
|
||||
expect(second.clearSession).toBe(true);
|
||||
});
|
||||
|
||||
it("maps a failed terminal to acpx_turn_failed and folds in the reported usage", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
events: async function* () {
|
||||
yield {
|
||||
type: "status",
|
||||
text: "usage",
|
||||
tag: "usage_update",
|
||||
cost: { amount: 0.31, currency: "USD" },
|
||||
breakdown: { inputTokens: 40, outputTokens: 700, cachedReadTokens: 60 },
|
||||
};
|
||||
yield { type: "done", stopReason: "failed" };
|
||||
},
|
||||
result: Promise.resolve({ status: "failed", error: new Error("boom") }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-failed",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("acpx_turn_failed");
|
||||
// The failed error message becomes the terminal stop reason and the summary.
|
||||
expect(result.summary).toBe("boom");
|
||||
expect((result.resultJson as Record<string, unknown>)?.stopReason).toBe("boom");
|
||||
// The usage math folds the usage_update event into per-run usage and cost.
|
||||
expect(result.usage).toEqual({ inputTokens: 40, outputTokens: 700, cachedInputTokens: 60 });
|
||||
expect(result.usageBasis).toBe("per_run");
|
||||
expect(result.costUsd).toBeCloseTo(0.31);
|
||||
});
|
||||
|
||||
it("computes usage the same way summarizeAcpxTurnUsage does for the event fallback", () => {
|
||||
// Pin the exported helper the turn path calls: with no getStatus snapshots the
|
||||
// event breakdown and cost drive the per-run usage.
|
||||
const summary = summarizeAcpxTurnUsage({
|
||||
preStatus: null,
|
||||
postStatus: null,
|
||||
eventBreakdown: { inputTokens: 40, outputTokens: 700, cachedReadTokens: 60 },
|
||||
eventCostUsd: 0.31,
|
||||
});
|
||||
expect(summary.usage).toEqual({ inputTokens: 40, outputTokens: 700, cachedInputTokens: 60 });
|
||||
expect(summary.costUsd).toBeCloseTo(0.31);
|
||||
});
|
||||
|
||||
it("returns a prepare_turn error result when the prompt build throws", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
let startTurnCalls = 0;
|
||||
let closes = 0;
|
||||
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
turnRuntime({
|
||||
onStartTurn: () => {
|
||||
startTurnCalls += 1;
|
||||
},
|
||||
onClose: () => {
|
||||
closes += 1;
|
||||
},
|
||||
events: async function* () {},
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
// A throwing accessor on a field only buildPrompt reads makes the prompt build
|
||||
// fail after the session handshake succeeds but before startTurn runs.
|
||||
const context: Record<string, unknown> = {};
|
||||
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error("prompt build boom");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-prepare-fail",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
||||
context,
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
// The pre-turn failure reports phase "prepare_turn".
|
||||
expect((result.resultJson as Record<string, unknown>)?.phase).toBe("prepare_turn");
|
||||
// The turn never started, and the runtime closed once.
|
||||
expect(startTurnCalls).toBe(0);
|
||||
expect(closes).toBe(1);
|
||||
});
|
||||
|
||||
it("threads the same happy-path turn through the shared runExecutor harness", async () => {
|
||||
// A smoke pin on the copied harness: the always-happy buildRuntime turn exits
|
||||
// clean with the done stop reason as the summary.
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const { result } = await runExecutor({
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.summary).toBe("end_turn");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runTurn, type StartedTurn, type TurnFinalizeInput, type TurnSteps } from "./turn-sequence.js";
|
||||
import { createRunResourceLedger } from "./run-resource-ledger.js";
|
||||
import type { TurnCompletion } from "./run-contracts.js";
|
||||
|
||||
// The turn sequence owns the run order, the wall-clock timer, and the abort
|
||||
// controller. These tests drive it with fakes for the five steps and assert the
|
||||
// order, the never-reject guarantee, and the timer ownership. They never touch
|
||||
// the engine's real run state.
|
||||
|
||||
function finalizedTurn(): TurnCompletion {
|
||||
return { kind: "finalized" };
|
||||
}
|
||||
|
||||
function failedTurn(error: unknown): TurnCompletion {
|
||||
return {
|
||||
kind: "failed",
|
||||
cause: { kind: "turn_failed", error: error instanceof Error ? error : new Error(String(error)) },
|
||||
resources: createRunResourceLedger().takeForSettlement(),
|
||||
};
|
||||
}
|
||||
|
||||
function noopStarted(): StartedTurn {
|
||||
return { cancel: async () => {} };
|
||||
}
|
||||
|
||||
// A base set of steps whose bodies do nothing. Each test overrides the steps it
|
||||
// drives and records the calls it cares about.
|
||||
function baseSteps(order: string[], overrides: Partial<TurnSteps<string>> = {}): TurnSteps<string> {
|
||||
return {
|
||||
timeoutMs: overrides.timeoutMs,
|
||||
timeoutMessage: overrides.timeoutMessage ?? "timed out",
|
||||
promptBuild: overrides.promptBuild ?? (async () => {
|
||||
order.push("promptBuild");
|
||||
}),
|
||||
preTurnUsage: overrides.preTurnUsage ?? (async () => {
|
||||
order.push("preTurnUsage");
|
||||
}),
|
||||
turnStart: overrides.turnStart ?? (() => {
|
||||
order.push("turnStart");
|
||||
return noopStarted();
|
||||
}),
|
||||
eventRelay: overrides.eventRelay ?? (async () => {
|
||||
order.push("eventRelay");
|
||||
return "terminal";
|
||||
}),
|
||||
turnFinalize: overrides.turnFinalize ?? (async (input) => {
|
||||
order.push(`turnFinalize:${input.kind}`);
|
||||
return input.kind === "terminal" ? finalizedTurn() : failedTurn(input.error);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ACPX turn sequence", () => {
|
||||
it("test_run_turn_never_rejects_every_error_is_a_completion", async () => {
|
||||
// A step error must become a `TurnCompletion`, never a rejection. The event
|
||||
// relay throws after the turn started, so the sequence reports a `turn`
|
||||
// failure and returns a completion.
|
||||
const finalizeInputs: TurnFinalizeInput<string>[] = [];
|
||||
const boom = new Error("relay boom");
|
||||
const steps = baseSteps([], {
|
||||
eventRelay: async () => {
|
||||
throw boom;
|
||||
},
|
||||
turnFinalize: async (input) => {
|
||||
finalizeInputs.push(input);
|
||||
return input.kind === "terminal" ? finalizedTurn() : failedTurn(input.error);
|
||||
},
|
||||
});
|
||||
|
||||
// The returned promise resolves; it never rejects.
|
||||
const completion = await runTurn(steps);
|
||||
expect(completion.kind).toBe("failed");
|
||||
// The error routed through the one finalize step with the `turn` phase.
|
||||
expect(finalizeInputs).toHaveLength(1);
|
||||
expect(finalizeInputs[0]).toMatchObject({ kind: "error", error: boom, phase: "turn" });
|
||||
});
|
||||
|
||||
it("test_run_turn_owns_timer_and_abort_controller", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const captured: { signal: AbortSignal | null; cancelReason: string | null } = {
|
||||
signal: null,
|
||||
cancelReason: null,
|
||||
};
|
||||
let releaseRelay: (value: string) => void = () => {};
|
||||
const relayPromise = new Promise<string>((resolve) => {
|
||||
releaseRelay = resolve;
|
||||
});
|
||||
const finalizeInputs: TurnFinalizeInput<string>[] = [];
|
||||
const steps = baseSteps([], {
|
||||
timeoutMs: 1000,
|
||||
timeoutMessage: "wall-clock timeout",
|
||||
turnStart: (signal) => {
|
||||
captured.signal = signal;
|
||||
return {
|
||||
cancel: async (reason) => {
|
||||
captured.cancelReason = reason;
|
||||
},
|
||||
};
|
||||
},
|
||||
eventRelay: async () => relayPromise,
|
||||
turnFinalize: async (input) => {
|
||||
finalizeInputs.push(input);
|
||||
return input.kind === "terminal" ? finalizedTurn() : failedTurn(input.error);
|
||||
},
|
||||
});
|
||||
|
||||
const runPromise = runTurn(steps);
|
||||
// Let promptBuild, preTurnUsage, and turnStart run.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(captured.signal?.aborted).toBe(false);
|
||||
// Fire the wall-clock timeout the sequence owns.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
// The sequence owns the abort controller: the shared signal is aborted...
|
||||
expect(captured.signal?.aborted).toBe(true);
|
||||
// ...and it cancelled the started turn with the timeout message.
|
||||
expect(captured.cancelReason).toBe("wall-clock timeout");
|
||||
// Complete the relay so the sequence finalizes the timed-out turn.
|
||||
releaseRelay("terminal");
|
||||
const completion = await runPromise;
|
||||
expect(completion).toEqual({ kind: "finalized" });
|
||||
// The finalize step saw the timeout flag the sequence tracked.
|
||||
expect(finalizeInputs).toHaveLength(1);
|
||||
expect(finalizeInputs[0]).toMatchObject({ kind: "terminal", timedOut: true });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("test_prompt_build_failure_is_failed_completion_with_prepare_turn", async () => {
|
||||
// A prompt-build failure happens before the turn starts, so the sequence
|
||||
// reports a `prepare_turn` failure and never runs the later steps.
|
||||
const order: string[] = [];
|
||||
const finalizeInputs: TurnFinalizeInput<string>[] = [];
|
||||
const boom = new Error("prompt build boom");
|
||||
const steps = baseSteps(order, {
|
||||
promptBuild: async () => {
|
||||
throw boom;
|
||||
},
|
||||
turnFinalize: async (input) => {
|
||||
finalizeInputs.push(input);
|
||||
return input.kind === "terminal" ? finalizedTurn() : failedTurn(input.error);
|
||||
},
|
||||
});
|
||||
|
||||
const completion = await runTurn(steps);
|
||||
expect(completion.kind).toBe("failed");
|
||||
expect(finalizeInputs).toHaveLength(1);
|
||||
expect(finalizeInputs[0]).toMatchObject({ kind: "error", error: boom, phase: "prepare_turn" });
|
||||
// The later steps never ran.
|
||||
expect(order).not.toContain("preTurnUsage");
|
||||
expect(order).not.toContain("turnStart");
|
||||
expect(order).not.toContain("eventRelay");
|
||||
});
|
||||
|
||||
it("test_run_turn_finalizes_no_resource", async () => {
|
||||
// Structural proof: the sequence borrows the resources and finalizes none.
|
||||
// The `TurnSteps` contract has no resource-release method; the sequence runs
|
||||
// only the five ordered steps. A test-side resource whose release the
|
||||
// sequence could never call stays untouched, and the recorded call order is
|
||||
// exactly the five steps. The coordinator settles the resources after the
|
||||
// turn, so a resource release inside the sequence would be a double release.
|
||||
const order: string[] = [];
|
||||
let released = false;
|
||||
// A resource the sequence has no hook to release. If the sequence ever
|
||||
// released a resource, it would have to reach one, and it cannot.
|
||||
const borrowedResource = {
|
||||
release: () => {
|
||||
released = true;
|
||||
},
|
||||
};
|
||||
void borrowedResource;
|
||||
|
||||
const steps = baseSteps(order);
|
||||
const completion = await runTurn(steps);
|
||||
|
||||
expect(completion).toEqual({ kind: "finalized" });
|
||||
// The sequence ran exactly the five ordered steps, then finalized.
|
||||
expect(order).toEqual([
|
||||
"promptBuild",
|
||||
"preTurnUsage",
|
||||
"turnStart",
|
||||
"eventRelay",
|
||||
"turnFinalize:terminal",
|
||||
]);
|
||||
// It never released the borrowed resource.
|
||||
expect(released).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// The turn sequence.
|
||||
//
|
||||
// `runTurn(steps)` is a plain sequence. It runs five steps in a fixed order:
|
||||
// promptBuild, preTurnUsage, turnStart, eventRelay, turnFinalize. It never
|
||||
// rejects: every step error becomes a typed `TurnCompletion` through the
|
||||
// `turnFinalize` step. The sequence owns the wall-clock timer and the abort
|
||||
// controller. It borrows the run resources; it finalizes no resource. The
|
||||
// coordinator (Phase 20) settles the resources after the turn.
|
||||
//
|
||||
// The sequence is data, not a graph and not a state machine. The engine supplies
|
||||
// each step; the sequence owns only the order, the timer, and the abort signal.
|
||||
// This keeps the sequence free of the engine's private run state.
|
||||
|
||||
import type { TurnCompletion } from "./run-contracts.js";
|
||||
|
||||
/**
|
||||
* A turn the site started. The sequence borrows it to cancel it on a timeout; it
|
||||
* releases nothing. The engine's `eventRelay` step reads the events and the
|
||||
* terminal result.
|
||||
*/
|
||||
export interface StartedTurn {
|
||||
cancel(reason: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The input the sequence hands to the one `turnFinalize` step. A `terminal`
|
||||
* input carries the turn's terminal result; an `error` input carries the step
|
||||
* error and the phase it failed in. Both carry the wall-clock timeout flag.
|
||||
*/
|
||||
export type TurnFinalizeInput<TTerminal> =
|
||||
| { readonly kind: "terminal"; readonly terminal: TTerminal; readonly timedOut: boolean }
|
||||
| {
|
||||
readonly kind: "error";
|
||||
readonly error: unknown;
|
||||
readonly phase: "prepare_turn" | "turn";
|
||||
readonly timedOut: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The five steps the sequence runs, plus the timer inputs. The engine implements
|
||||
* each step over its own run state. `turnFinalize` maps a terminal result or a
|
||||
* step error to a `TurnCompletion`; it must not reject, so the sequence never
|
||||
* rejects.
|
||||
*/
|
||||
export interface TurnSteps<TTerminal> {
|
||||
/** The wall-clock timeout, in milliseconds, or undefined for no timeout. */
|
||||
readonly timeoutMs: number | undefined;
|
||||
/** The message the timeout cancel carries. */
|
||||
readonly timeoutMessage: string;
|
||||
/** Build the prompt and emit the run metadata. A failure here is `prepare_turn`. */
|
||||
promptBuild(signal: AbortSignal): Promise<void>;
|
||||
/** Snapshot the pre-turn usage, so this run's cost excludes earlier runs. */
|
||||
preTurnUsage(): Promise<void>;
|
||||
/** Start the turn with the run's six inputs. The sequence adds the signal and the timeout. */
|
||||
turnStart(signal: AbortSignal, timeoutMs: number | undefined): StartedTurn;
|
||||
/** Relay the turn events and return the terminal result. */
|
||||
eventRelay(turn: StartedTurn): Promise<TTerminal>;
|
||||
/** Map a terminal result or a step error to a `TurnCompletion`. Must not reject. */
|
||||
turnFinalize(input: TurnFinalizeInput<TTerminal>): Promise<TurnCompletion>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the turn sequence. It returns a `TurnCompletion` on every path and never
|
||||
* rejects. A failure before `turnStart` returns is a `prepare_turn` failure; a
|
||||
* failure after it is a `turn` failure. Only the reported phase differs.
|
||||
*/
|
||||
export async function runTurn<TTerminal>(steps: TurnSteps<TTerminal>): Promise<TurnCompletion> {
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let timedOut = false;
|
||||
let started: StartedTurn | null = null;
|
||||
// `turnStarted` separates a pre-turn preparation failure from a running-turn
|
||||
// failure, so `turnFinalize` reports the right phase.
|
||||
let turnStarted = false;
|
||||
try {
|
||||
// Build the prompt and snapshot the pre-turn usage inside the failure
|
||||
// boundary. A failure here is a `prepare_turn` failure.
|
||||
await steps.promptBuild(controller.signal);
|
||||
await steps.preTurnUsage();
|
||||
// The sequence owns the wall-clock timer. On a timeout it marks the run timed
|
||||
// out, aborts the shared signal, and cancels the started turn. The cancel
|
||||
// no-ops before `turnStart` returns, because `started` is still null.
|
||||
if (steps.timeoutMs !== undefined) {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
void started?.cancel(steps.timeoutMessage).catch(() => {});
|
||||
}, steps.timeoutMs);
|
||||
}
|
||||
started = steps.turnStart(controller.signal, steps.timeoutMs);
|
||||
turnStarted = true;
|
||||
const terminal = await steps.eventRelay(started);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return await steps.turnFinalize({ kind: "terminal", terminal, timedOut });
|
||||
} catch (error) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
const phase: "prepare_turn" | "turn" = turnStarted ? "turn" : "prepare_turn";
|
||||
return await steps.turnFinalize({ kind: "error", error, phase, timedOut });
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
|
||||
import type { RunProcessResult } from "./server-utils.js";
|
||||
import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress.js";
|
||||
import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js";
|
||||
|
||||
export interface CommandManagedRuntimeRunner {
|
||||
/**
|
||||
@@ -34,6 +35,27 @@ export interface CommandManagedRuntimeRunner {
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
/**
|
||||
* Run this command through the lease's persistent session even when no run
|
||||
* step is active. A sandbox provider opens the session on the first
|
||||
* non-bypassed command; the ACP process session bridge sets this so the
|
||||
* long-lived agent command streams its output through the session log
|
||||
* stream. The default keeps the context-based session selection.
|
||||
*/
|
||||
useSession?: boolean;
|
||||
/**
|
||||
* Run this command outside the lease's persistent session even when a run
|
||||
* step is active. The persistent session is a single serialized shell. In
|
||||
* streamed mode the agent runs as one long-lived foreground command that
|
||||
* holds the session for the whole run. The bridge control-plane execs
|
||||
* (input delivery, output read, callback relay, and the queue/setup
|
||||
* bookkeeping) must run concurrently with the agent, so they run as
|
||||
* independent one-shot commands. On the session they queue behind the agent
|
||||
* command that never returns — a permanent deadlock. An explicit bypass
|
||||
* always wins over the context-based session selection and over
|
||||
* `useSession`. The default keeps the context-based session selection.
|
||||
*/
|
||||
bypassSession?: boolean;
|
||||
}): Promise<RunProcessResult>;
|
||||
/**
|
||||
* Optional native inbound file transfer. Present only when the sandbox
|
||||
@@ -457,6 +479,10 @@ export async function prepareCommandManagedRuntime(input: {
|
||||
// task wires it into the byte-counting writeFile/readFile transport.
|
||||
onProgress?: RuntimeProgressSink;
|
||||
onRuntimeProgress?: RuntimeStatusSink;
|
||||
// Optional host span runner for the workspace tarball build. Forwarded to
|
||||
// prepareSandboxManagedRuntime so the host pack time rides one `pack` span
|
||||
// under the `stage.sync` step. The default is a no-op.
|
||||
runtimeSpan?: RuntimeSpanRunner;
|
||||
}): Promise<PreparedSandboxManagedRuntime> {
|
||||
const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000;
|
||||
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
||||
@@ -508,6 +534,7 @@ export async function prepareCommandManagedRuntime(input: {
|
||||
additionalSources: input.additionalSources,
|
||||
onProgress: input.onProgress,
|
||||
onRuntimeProgress: input.onRuntimeProgress,
|
||||
runtimeSpan: input.runtimeSpan,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -546,5 +573,6 @@ export async function prepareCommandManagedRuntime(input: {
|
||||
additionalSources: input.additionalSources,
|
||||
onProgress: input.onProgress,
|
||||
onRuntimeProgress: input.onRuntimeProgress,
|
||||
runtimeSpan: input.runtimeSpan,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { REDACTED_COMMAND_TEXT_VALUE, redactDiagnosticText } from "./command-redaction.js";
|
||||
|
||||
describe("redactDiagnosticText", () => {
|
||||
it("redacts a JSON secret field value", () => {
|
||||
const input = '{"token":"opaque-value","status":"error"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("opaque-value");
|
||||
expect(output).toContain(`"token":"${REDACTED_COMMAND_TEXT_VALUE}"`);
|
||||
// The non-secret field keeps its value.
|
||||
expect(output).toContain('"status":"error"');
|
||||
});
|
||||
|
||||
it("redacts an api_key JSON field with whitespace around the colon", () => {
|
||||
const input = '{ "api_key" : "sk-secret-123" }';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("sk-secret-123");
|
||||
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
||||
});
|
||||
|
||||
it("redacts an escaped-JSON secret field value", () => {
|
||||
// A diagnostic can carry a JSON string, so the double quotes appear as `\"`.
|
||||
const input = '{\\"token\\":\\"opaque-value\\"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("opaque-value");
|
||||
expect(output).toContain(`\\"token\\":\\"${REDACTED_COMMAND_TEXT_VALUE}\\"`);
|
||||
});
|
||||
|
||||
it("still redacts a shell KEY=value secret", () => {
|
||||
const input = "ANTHROPIC_API_KEY=super-secret-value claude --print";
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("super-secret-value");
|
||||
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
||||
});
|
||||
|
||||
it("keeps non-secret text and non-secret JSON fields intact", () => {
|
||||
const input = '{"status":"ok","message":"probe finished"}';
|
||||
expect(redactDiagnosticText(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("redacts the secret but keeps a non-secret marker in the same string", () => {
|
||||
const input = 'DIAGMARKER1234 said {"authorization":"Bearer opaque"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).toContain("DIAGMARKER1234");
|
||||
expect(output).not.toContain("opaque");
|
||||
});
|
||||
|
||||
it("redacts a JSON secret value that contains an escaped quote", () => {
|
||||
// The value holds an escaped quote, so a naive matcher stops at the `\"` and
|
||||
// leaves the rest of the credential. The marker sits after the escaped quote.
|
||||
const input = '{"token":"pre\\"MARKERQUOTE_A"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("MARKERQUOTE_A");
|
||||
expect(output).toContain(`"token":"${REDACTED_COMMAND_TEXT_VALUE}"`);
|
||||
});
|
||||
|
||||
it("redacts a JSON secret value that contains an escaped backslash", () => {
|
||||
const input = '{"secret":"pre\\\\MARKERBACKSLASH_A"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("MARKERBACKSLASH_A");
|
||||
expect(output).toContain(`"secret":"${REDACTED_COMMAND_TEXT_VALUE}"`);
|
||||
});
|
||||
|
||||
it("redacts an escaped-JSON secret value that contains an escaped quote", () => {
|
||||
// A diagnostic can carry a serialized JSON string, so the whole JSON is
|
||||
// escaped a second time. The inner value still holds an escaped quote.
|
||||
const innerJson = '{"token":"pre\\"MARKERQUOTE_B"}';
|
||||
const input = JSON.stringify(innerJson);
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("MARKERQUOTE_B");
|
||||
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
||||
});
|
||||
|
||||
it("redacts an escaped-JSON secret value that contains an escaped backslash", () => {
|
||||
const innerJson = '{"password":"pre\\\\MARKERBACKSLASH_B"}';
|
||||
const input = JSON.stringify(innerJson);
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("MARKERBACKSLASH_B");
|
||||
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
||||
});
|
||||
});
|
||||
@@ -56,3 +56,37 @@ export function redactCommandText(command: string, redactedValue = REDACTED_COMM
|
||||
.replace(COMMAND_GITHUB_TOKEN_RE, redactedValue)
|
||||
.replace(COMMAND_JWT_RE, redactedValue);
|
||||
}
|
||||
|
||||
// A JSON secret field is a key/value pair such as `"token":"opaque-value"`. The
|
||||
// command redaction handles shell `KEY=value` syntax only. A sandbox diagnostic
|
||||
// can also carry a serialized JSON error, so the sanitizer must redact the JSON
|
||||
// form too. The value body consumes JSON escape sequences. An escaped quote
|
||||
// (`\"`) inside the value does not end the match early.
|
||||
const JSON_SECRET_FIELD_RE = new RegExp(
|
||||
String.raw`("(?:${SECRET_NAME_PATTERN})"\s*:\s*")(?:\\[\s\S]|[^"\\])*(")`,
|
||||
"gi",
|
||||
);
|
||||
// An escaped JSON secret field is the same pair inside a JSON string. The double
|
||||
// quote appears as `\"` and a backslash appears as `\\`. The value body
|
||||
// consumes the doubled escape sequences. An escaped quote inside the value does
|
||||
// not end the match early. The value ends at the next unescaped `\"`.
|
||||
const JSON_ESCAPED_SECRET_FIELD_RE = new RegExp(
|
||||
String.raw`(\\"(?:${SECRET_NAME_PATTERN})\\"\s*:\s*\\")(?:\\\\\\\\|\\\\\\"|\\\\[\s\S]|[^\\"])*(\\")`,
|
||||
"gi",
|
||||
);
|
||||
|
||||
/**
|
||||
* Redact secrets from an untrusted diagnostic string.
|
||||
*
|
||||
* The function first runs the command redaction. The command redaction handles
|
||||
* shell `KEY=value` assignments, CLI secret options, bearer headers, and common
|
||||
* token shapes. The function then redacts JSON and escaped-JSON secret fields,
|
||||
* because a sandbox diagnostic can carry a serialized JSON error such as
|
||||
* `{"token":"opaque-value"}`. The caller must still bound the length after this
|
||||
* step.
|
||||
*/
|
||||
export function redactDiagnosticText(text: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string {
|
||||
return redactCommandText(text, redactedValue)
|
||||
.replace(JSON_ESCAPED_SECRET_FIELD_RE, `$1${redactedValue}$2`)
|
||||
.replace(JSON_SECRET_FIELD_RE, `$1${redactedValue}$2`);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
formatAdapterExecutionTimeoutErrorMessage,
|
||||
formatAdapterExecutionTimeoutStartLogLine,
|
||||
postedIssueCommentLogMarker,
|
||||
resolveAdapterExecutionTargetTimeout,
|
||||
resolveAdapterExecutionTargetTimeoutSec,
|
||||
runAdapterExecutionTargetProcess,
|
||||
@@ -23,16 +24,65 @@ import {
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
type AdapterSandboxExecutionTarget,
|
||||
} from "./execution-target.js";
|
||||
import { getActiveStepContext } from "./acpx-engine/startup-timing.js";
|
||||
import {
|
||||
createRuntimeSpanRunner,
|
||||
getActiveStepContext,
|
||||
type StartupSpan,
|
||||
type StartupTraceContext,
|
||||
type StartupTracer,
|
||||
} from "./acpx-engine/startup-timing.js";
|
||||
import { createSandboxRunLogTailFactory } from "./sandbox-run-log-stream.js";
|
||||
import { runChildProcess } from "./server-utils.js";
|
||||
import { shellQuote } from "./ssh.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type RecordedSpan = { name: string; parentName: string | null; ended: boolean };
|
||||
|
||||
/**
|
||||
* A structural tracer that records each opened span's name, parent, and end
|
||||
* state, so a test can assert the trace shape a runtime span runner produces.
|
||||
* Mirrors the recorder used for the `pack`/`stage.sync` nesting tests.
|
||||
*/
|
||||
function createRecordingTraceContext(): {
|
||||
traceContext: StartupTraceContext;
|
||||
spans: RecordedSpan[];
|
||||
} {
|
||||
const spans: RecordedSpan[] = [];
|
||||
const byHandle = new WeakMap<StartupSpan, RecordedSpan>();
|
||||
const tracer: StartupTracer = {
|
||||
startSpan(name, _options, context) {
|
||||
const parent = context as RecordedSpan | undefined;
|
||||
const record: RecordedSpan = { name, parentName: parent?.name ?? null, ended: false };
|
||||
spans.push(record);
|
||||
const handle: StartupSpan = {
|
||||
setAttribute() {},
|
||||
setStatus() {},
|
||||
end() {
|
||||
record.ended = true;
|
||||
},
|
||||
};
|
||||
byHandle.set(handle, record);
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
const traceContext: StartupTraceContext = {
|
||||
tracer,
|
||||
contextWithSpan: (span) => byHandle.get(span),
|
||||
};
|
||||
return { traceContext, spans };
|
||||
}
|
||||
|
||||
describe("sandbox adapter execution targets", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
it("records successful issue comment ids for attribution recovery", () => {
|
||||
expect(postedIssueCommentLogMarker("POST", "/api/issues/issue-1/comments", 201, '{"id":"comment-1"}'))
|
||||
.toBe("comment id: comment-1\n");
|
||||
expect(postedIssueCommentLogMarker("POST", "/api/issues/issue-1/comments", 401, '{"id":"comment-1"}'))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
while (cleanupDirs.length > 0) {
|
||||
@@ -106,7 +156,22 @@ describe("sandbox adapter execution targets", () => {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function runProxyWithInput(command: string, input: string): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
type ProxyRunResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
/**
|
||||
* How long the exchange took. The bridge and the proxy both run on 5s
|
||||
* budgets, which is generous locally and tight on a CI runner sharing a
|
||||
* box with 19 other lanes. A run that returns fast and empty is a
|
||||
* different fault from one that nearly hit the ceiling, and the numbers
|
||||
* are the only way to tell them apart after the fact.
|
||||
*/
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
async function runProxyWithInput(command: string, input: string): Promise<ProxyRunResult> {
|
||||
const startedAt = performance.now();
|
||||
const child = spawn(command, [], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
@@ -133,7 +198,65 @@ describe("sandbox adapter execution targets", () => {
|
||||
resolve(exitCode);
|
||||
});
|
||||
});
|
||||
return { stdout, stderr, code };
|
||||
return { stdout, stderr, code, elapsedMs: Math.round(performance.now() - startedAt) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure report for a proxy exchange, attached to the assertions below.
|
||||
*
|
||||
* `execution-target-sandbox` has failed twice in CI and never once in a few
|
||||
* hundred local runs, so the next occurrence has to carry its own evidence -
|
||||
* a second unreproducible failure teaches nothing. The observed signature was
|
||||
* an empty stdout with exit code 0, meaning the child exited cleanly having
|
||||
* produced nothing, which is what a lost stdin frame looks like from here.
|
||||
*
|
||||
* The runtime tree is the part that discriminates. The stdin queue files are
|
||||
* written by the host and deleted by the wrapper once parsed, so what remains
|
||||
* says whether the frame was never written, written and never consumed, or
|
||||
* consumed normally and the reply lost on the way back.
|
||||
*/
|
||||
async function describeProxyRun(result: ProxyRunResult, runtimeRootDir: string): Promise<string> {
|
||||
const lines = [
|
||||
`proxy exit=${result.code} elapsedMs=${result.elapsedMs}`,
|
||||
`proxy stdout=${JSON.stringify(result.stdout)}`,
|
||||
`proxy stderr=${JSON.stringify(result.stderr)}`,
|
||||
];
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
// Deep enough to reach the queue frames, which are the point. They sit
|
||||
// at process-sessions/<id>/stdin/<seq>.json — depth 4 from the runtime
|
||||
// root — so a cap of 3 listed the `stdin/` directory and stopped, making
|
||||
// "the queue is empty" and "the walk never looked" print identically.
|
||||
if (depth > 5) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
lines.push(`${" ".repeat(depth)}<unreadable ${dir}: ${(error as Error).message}>`);
|
||||
return;
|
||||
}
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
lines.push(`${" ".repeat(depth)}${entry.name}/`);
|
||||
await walk(full, depth + 1);
|
||||
continue;
|
||||
}
|
||||
// Small files are the queue and event frames, and their contents are
|
||||
// the point. Anything larger is a child script or a log; the size is
|
||||
// enough to say it exists.
|
||||
let detail = "";
|
||||
try {
|
||||
const raw = await readFile(full, "utf8");
|
||||
detail = raw.length <= 400 ? ` ${JSON.stringify(raw)}` : ` <${raw.length}B>`;
|
||||
} catch (error) {
|
||||
detail = ` <unreadable: ${(error as Error).message}>`;
|
||||
}
|
||||
lines.push(`${" ".repeat(depth)}${entry.name}${detail}`);
|
||||
}
|
||||
};
|
||||
lines.push(`runtime tree under ${runtimeRootDir}:`);
|
||||
await walk(runtimeRootDir, 1);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function combinedStream(
|
||||
@@ -195,6 +318,48 @@ describe("sandbox adapter execution targets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves stdin when wrapping sandbox adapter commands for run-log streaming", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-run-log-stdin-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
streamRunLogs: true,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
const logsDir = path.posix.join(rootDir, ".paperclip-runtime", "bridge", "logs");
|
||||
const runLogTail = createSandboxRunLogTailFactory({
|
||||
runner: target.runner!,
|
||||
remoteCwd: rootDir,
|
||||
logsDir,
|
||||
shellCommand: "bash",
|
||||
}).create();
|
||||
const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
||||
|
||||
const result = await runAdapterExecutionTargetProcess(
|
||||
"run-log-stdin",
|
||||
target,
|
||||
process.execPath,
|
||||
["-e", "process.stdin.setEncoding('utf8'); let s=''; process.stdin.on('data', c => s += c); process.stdin.on('end', () => process.stdout.write('stdin=' + s));"],
|
||||
{
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
stdin: "hello-through-wrapper",
|
||||
timeoutSec: 5,
|
||||
graceSec: 1,
|
||||
runLogTail: { create: () => runLogTail },
|
||||
onLog: async (stream, chunk) => { events.push({ stream, chunk }); },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("stdin=hello-through-wrapper");
|
||||
expect(combinedStream(events, "stdout")).toContain("stdin=hello-through-wrapper");
|
||||
});
|
||||
|
||||
it("creates the process session directories only in the launch exec, not in upfront makeDir execs", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-makedir-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
@@ -645,9 +810,10 @@ describe("sandbox adapter execution targets", () => {
|
||||
|
||||
try {
|
||||
const result = await runProxyWithInput(bridge!.agentCommand, "hello\n");
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe("out:hello\n");
|
||||
expect(result.stderr).toBe("err:hello\n");
|
||||
const report = await describeProxyRun(result, path.posix.join(rootDir, ".paperclip-runtime", "acpx"));
|
||||
expect(result.code, report).toBe(0);
|
||||
expect(result.stdout, report).toBe("out:hello\n");
|
||||
expect(result.stderr, report).toBe("err:hello\n");
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
}
|
||||
@@ -901,6 +1067,530 @@ describe("sandbox adapter execution targets", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("streamed output (streamOutputViaSession)", () => {
|
||||
it("bridges bidirectional sessions when the wrapper streams output to stdout", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-echo-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "echo-acp-child.mjs");
|
||||
await writeFile(
|
||||
childPath,
|
||||
[
|
||||
"process.stdin.on('data', (chunk) => {",
|
||||
" process.stdout.write('out:' + chunk.toString());",
|
||||
" process.stderr.write('err:' + chunk.toString());",
|
||||
"});",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stream-echo",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
streamOutputViaSession: true,
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
try {
|
||||
const result = await runProxyWithInput(bridge!.agentCommand, "hello\n");
|
||||
const report = await describeProxyRun(result, path.posix.join(rootDir, ".paperclip-runtime", "acpx"));
|
||||
expect(result.code, report).toBe(0);
|
||||
expect(result.stdout, report).toBe("out:hello\n");
|
||||
expect(result.stderr, report).toBe("err:hello\n");
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps the long-lived streamed launch in a sandbox.agentProcess span", async () => {
|
||||
// The streamed launch is fire-and-forget and lives for the whole run, so
|
||||
// its span must open under the live run root (not the ephemeral bring-up
|
||||
// step) and stay open around the launch. Record the opened span names and
|
||||
// prove `sandbox.agentProcess` is among them, and that a normal exchange
|
||||
// still works through the wrap.
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-span-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "echo-acp-child.mjs");
|
||||
await writeFile(
|
||||
childPath,
|
||||
[
|
||||
"process.stdin.on('data', (chunk) => {",
|
||||
" process.stdout.write('out:' + chunk.toString());",
|
||||
"});",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
|
||||
const spanNames: string[] = [];
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stream-span",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
streamOutputViaSession: true,
|
||||
// Record each wrapper span name, then run the wrapped work.
|
||||
runtimeSpan: async (name, work) => {
|
||||
spanNames.push(name);
|
||||
return work();
|
||||
},
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
try {
|
||||
// The launch span opens synchronously as the bridge starts, before any
|
||||
// frame flows, so it is observable as soon as the handle resolves.
|
||||
expect(spanNames).toContain("sandbox.agentProcess");
|
||||
const result = await runProxyWithInput(bridge!.agentCommand, "hello\n");
|
||||
const report = await describeProxyRun(result, path.posix.join(rootDir, ".paperclip-runtime", "acpx"));
|
||||
expect(result.code, report).toBe(0);
|
||||
expect(result.stdout, report).toBe("out:hello\n");
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("parents the sandbox.agentProcess span to the live run root, not the bring-up step", async () => {
|
||||
// The launch runs for the whole run, so its span must parent to the live
|
||||
// run root (here a stand-in `task.run`) rather than the ephemeral
|
||||
// `bridge.process-session` bring-up step — otherwise it dangles past its
|
||||
// parent and overlaps `agent.turn`. Build the real run-rooted runner from a
|
||||
// recording trace context and assert the recorded parent.
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-parent-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "noop-acp-child.mjs");
|
||||
await writeFile(childPath, "process.stdin.on('data', () => {});\n", "utf8");
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
|
||||
const { traceContext, spans } = createRecordingTraceContext();
|
||||
// The run root stands in for `task.run` — the parent the run-rooted runner
|
||||
// resolves at launch time, since no turn has started yet.
|
||||
const runRoot = traceContext.tracer.startSpan("task.run", undefined, undefined);
|
||||
const runRootContext = traceContext.contextWithSpan(runRoot);
|
||||
const runtimeSpan = createRuntimeSpanRunner(traceContext, () => runRootContext);
|
||||
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stream-parent",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
streamOutputViaSession: true,
|
||||
runtimeSpan,
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
try {
|
||||
const agentProcess = spans.find((span) => span.name === "sandbox.agentProcess");
|
||||
expect(agentProcess).toBeDefined();
|
||||
expect(agentProcess!.parentName).toBe("task.run");
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("ends the sandbox.agentProcess span at stop() even when the process lingers", async () => {
|
||||
// The span must not outlive the run root. When the remote process lingers
|
||||
// past bridge teardown (`execute` has no cancel), the span still has to end
|
||||
// at `stop()`, which the caller awaits before it ends `task.run`. Use a
|
||||
// child that ignores stdin and never exits on its own, so the launch
|
||||
// command stays pending across `stop()`, and prove the span ends anyway.
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-linger-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "linger-acp-child.mjs");
|
||||
await writeFile(
|
||||
childPath,
|
||||
[
|
||||
"process.stdin.on('data', () => {});",
|
||||
// Stay alive well past the assertions, then self-exit so the test
|
||||
// leaves no lingering process.
|
||||
"setTimeout(() => process.exit(0), 3000);",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
|
||||
// Track when each wrapper span's work settles (i.e. when its span ends).
|
||||
const spanRecords: Array<{ name: string; ended: boolean }> = [];
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stream-linger",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 10,
|
||||
onLog: async () => {},
|
||||
streamOutputViaSession: true,
|
||||
runtimeSpan: (name, work) => {
|
||||
const record = { name, ended: false };
|
||||
spanRecords.push(record);
|
||||
const promise = work();
|
||||
void promise.then(
|
||||
() => {
|
||||
record.ended = true;
|
||||
},
|
||||
() => {
|
||||
record.ended = true;
|
||||
},
|
||||
);
|
||||
return promise;
|
||||
},
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
const record = spanRecords.find((span) => span.name === "sandbox.agentProcess");
|
||||
expect(record).toBeDefined();
|
||||
// The launch command is still running, so the span is still open.
|
||||
expect(record!.ended).toBe(false);
|
||||
|
||||
// Teardown ends the span promptly, without waiting for the lingering command.
|
||||
await bridge!.stop();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(record!.ended).toBe(true);
|
||||
});
|
||||
|
||||
it("buffers streamed output until the local proxy connects", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-buffer-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "fast-stream-child.mjs");
|
||||
await writeFile(
|
||||
childPath,
|
||||
[
|
||||
"process.stdout.write('early-out\\n');",
|
||||
"process.stderr.write('early-err\\n');",
|
||||
"setTimeout(() => process.exit(0), 20);",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stream-buffer",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
streamOutputViaSession: true,
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const result = await runProxyWithInput(bridge!.agentCommand, "");
|
||||
expect(result.code).toBe(0);
|
||||
// The seq guard delivers the early output exactly once even though the
|
||||
// live stream and the terminal result both carry it.
|
||||
expect(result.stdout).toBe("early-out\n");
|
||||
expect(result.stderr).toBe("early-err\n");
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers full streamed output when the sandbox child exits immediately", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-fast-exit-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "instant-stream-child.mjs");
|
||||
await writeFile(
|
||||
childPath,
|
||||
[
|
||||
"process.stdout.write('final-out\\n');",
|
||||
"process.stderr.write('final-err\\n');",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stream-fast-exit",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
streamOutputViaSession: true,
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
try {
|
||||
const result = await runProxyWithInput(bridge!.agentCommand, "");
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe("final-out\n");
|
||||
expect(result.stderr).toBe("final-err\n");
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("streams live output before the child exits and never writes output event files", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-live-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "live-stream-child.mjs");
|
||||
await writeFile(
|
||||
childPath,
|
||||
[
|
||||
"process.stdin.setEncoding('utf8');",
|
||||
"process.stdin.on('data', (chunk) => {",
|
||||
" if (chunk.includes('ping')) {",
|
||||
" process.stdout.write('delta:ping\\n');",
|
||||
" process.stderr.write('trace:ping\\n');",
|
||||
" }",
|
||||
" if (chunk.includes('finish')) process.exit(0);",
|
||||
"});",
|
||||
"process.stdin.resume();",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stream-live",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
streamOutputViaSession: true,
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
const child = spawn(bridge!.agentCommand, [], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exited = false;
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const exitPromise = new Promise<number | null>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("Timed out waiting for streamed process session proxy."));
|
||||
}, 5000);
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("exit", (exitCode) => {
|
||||
exited = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(exitCode);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
child.stdin.write("ping\n");
|
||||
await waitForCondition(
|
||||
() => stdout.includes("delta:ping\n") && stderr.includes("trace:ping\n"),
|
||||
"Timed out waiting for live streamed process session output.",
|
||||
3000,
|
||||
);
|
||||
expect(exited).toBe(false);
|
||||
|
||||
child.stdin.end("finish\n");
|
||||
await expect(exitPromise).resolves.toBe(0);
|
||||
|
||||
// The streamed path uses the stdout wrapper, not the output-file poll, so
|
||||
// no `events` directory is ever created under the session runtime tree.
|
||||
const hasEventsDir = await readdir(
|
||||
path.posix.join(rootDir, ".paperclip-runtime", "acpx", "process-sessions"),
|
||||
{ withFileTypes: true, recursive: true },
|
||||
)
|
||||
.then((entries) => entries.some((entry) => entry.isDirectory() && entry.name === "events"))
|
||||
.catch(() => false);
|
||||
expect(hasEventsDir).toBe(false);
|
||||
} finally {
|
||||
if (!exited) {
|
||||
child.kill("SIGKILL");
|
||||
await exitPromise.catch(() => undefined);
|
||||
}
|
||||
await bridge?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the agent command on the persistent session and forces bridge control execs off it", async () => {
|
||||
// Regression guard for the streamed-mode startup deadlock. The persistent
|
||||
// session is one serialized shell. In streamed mode the agent runs as a
|
||||
// long-lived foreground session command that holds the session for the
|
||||
// whole run. The bridge control-plane execs (script sync, stdin delivery,
|
||||
// teardown) must run concurrently with the agent, so each must force
|
||||
// itself off the session. On the session they queue behind the agent
|
||||
// command that never returns, and the first handshake write never drains.
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-isolation-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "echo-acp-child.mjs");
|
||||
await writeFile(
|
||||
childPath,
|
||||
[
|
||||
"process.stdin.on('data', (chunk) => {",
|
||||
" process.stdout.write('out:' + chunk.toString());",
|
||||
"});",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const delegate = createLocalSandboxRunner();
|
||||
const execs: Array<{ useSession?: boolean; bypassSession?: boolean; script: string }> = [];
|
||||
const runner = {
|
||||
execute: vi.fn(
|
||||
async (
|
||||
input: Parameters<typeof delegate.execute>[0] & {
|
||||
useSession?: boolean;
|
||||
bypassSession?: boolean;
|
||||
},
|
||||
) => {
|
||||
execs.push({
|
||||
useSession: input.useSession,
|
||||
bypassSession: input.bypassSession,
|
||||
script: input.args?.[1] ?? "",
|
||||
});
|
||||
return delegate.execute(input);
|
||||
},
|
||||
),
|
||||
};
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stream-isolation",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
streamOutputViaSession: true,
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
try {
|
||||
// Round-trip one input so a stdin-delivery control exec runs and gets
|
||||
// recorded before the assertions below.
|
||||
const result = await runProxyWithInput(bridge!.agentCommand, "hello\n");
|
||||
expect(
|
||||
result.stdout,
|
||||
await describeProxyRun(result, path.posix.join(rootDir, ".paperclip-runtime", "acpx")),
|
||||
).toBe("out:hello\n");
|
||||
|
||||
// Exactly one exec runs on the persistent session: the long-lived agent
|
||||
// command. It streams its output through the session log stream, so it
|
||||
// must not also bypass the session.
|
||||
const sessionExecs = execs.filter((exec) => exec.useSession === true);
|
||||
expect(sessionExecs).toHaveLength(1);
|
||||
expect(sessionExecs[0]!.bypassSession).not.toBe(true);
|
||||
expect(sessionExecs[0]!.script).toContain("node ");
|
||||
|
||||
// Every other exec is bridge control-plane plumbing. Each must force
|
||||
// itself off the persistent session so it never queues behind the agent
|
||||
// command that holds it.
|
||||
const controlExecs = execs.filter((exec) => exec.useSession !== true);
|
||||
expect(controlExecs.length).toBeGreaterThan(0);
|
||||
for (const exec of controlExecs) {
|
||||
expect(exec.bypassSession).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => {
|
||||
const sandboxTarget: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
@@ -1840,4 +2530,221 @@ describe("sandbox adapter execution targets", () => {
|
||||
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards the host indeterminate-outcome header so the sandbox server maps the 504 to a non-retryable 409", async () => {
|
||||
// The host marks a possibly-committed mutation with a 504 and the
|
||||
// `x-paperclip-bridge-outcome: indeterminate` header. The forward must keep
|
||||
// that header, so the in-sandbox server maps the 504 to a non-retryable 409.
|
||||
// If the forward drops the header, the client sees a retryable 504 and a
|
||||
// retry repeats a mutation that already committed.
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-outcome-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
|
||||
await mkdir(runtimeRootDir, { recursive: true });
|
||||
|
||||
const responseBody = JSON.stringify({ error: "Mutation outcome is indeterminate.", outcome: "indeterminate", retryable: false });
|
||||
const apiServer = createServer((_req, res) => {
|
||||
res.writeHead(504, {
|
||||
"content-type": "application/json",
|
||||
"x-paperclip-bridge-outcome": "indeterminate",
|
||||
});
|
||||
res.end(responseBody);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
apiServer.once("error", reject);
|
||||
apiServer.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = apiServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected the bridge outcome test API server to listen on a TCP port.");
|
||||
}
|
||||
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd,
|
||||
runner: createLocalSandboxRunner(),
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId: "run-bridge-outcome",
|
||||
target,
|
||||
runtimeRootDir,
|
||||
adapterKey: "codex",
|
||||
hostApiToken: "real-run-jwt",
|
||||
hostApiUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/issues/issue-1/comments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ body: "Status update." }),
|
||||
});
|
||||
|
||||
// The sandbox server maps the indeterminate 504 to a non-retryable 409.
|
||||
expect(response.status).toBe(409);
|
||||
// The outcome header and body still reach the client, so a caller that
|
||||
// reads them still sees the indeterminate result.
|
||||
expect(response.headers.get("x-paperclip-bridge-outcome")).toBe("indeterminate");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "Mutation outcome is indeterminate.",
|
||||
outcome: "indeterminate",
|
||||
retryable: false,
|
||||
});
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards bridge traffic to the local listen origin even when public API URLs are configured", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-local-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "claude");
|
||||
await mkdir(runtimeRootDir, { recursive: true });
|
||||
|
||||
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
|
||||
const apiServer = createServer((req, res) => {
|
||||
requests.push({
|
||||
method: req.method ?? "GET",
|
||||
url: req.url ?? "/",
|
||||
auth: req.headers.authorization ?? null,
|
||||
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
|
||||
});
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
apiServer.once("error", reject);
|
||||
apiServer.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = apiServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected the bridge local-origin test API server to listen on a TCP port.");
|
||||
}
|
||||
|
||||
// Simulate a deployment where a public base URL is configured: server boot
|
||||
// exports the public origin via PAPERCLIP_RUNTIME_API_URL / PAPERCLIP_API_URL
|
||||
// and the local listen host/port via PAPERCLIP_LISTEN_HOST / PAPERCLIP_LISTEN_PORT.
|
||||
// The wildcard listen host must map to the loopback address of the same
|
||||
// family (0.0.0.0 -> 127.0.0.1), where the test API server is bound.
|
||||
vi.stubEnv("PAPERCLIP_RUNTIME_API_URL", "https://public.example.invalid");
|
||||
vi.stubEnv("PAPERCLIP_API_URL", "https://public.example.invalid");
|
||||
vi.stubEnv("PAPERCLIP_LISTEN_HOST", "0.0.0.0");
|
||||
vi.stubEnv("PAPERCLIP_LISTEN_PORT", String(address.port));
|
||||
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd,
|
||||
runner: createLocalSandboxRunner(),
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId: "run-bridge-local",
|
||||
target,
|
||||
runtimeRootDir,
|
||||
adapterKey: "claude",
|
||||
hostApiToken: "real-run-jwt",
|
||||
});
|
||||
try {
|
||||
expect(bridge).not.toBeNull();
|
||||
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ ok: true });
|
||||
expect(requests).toEqual([{
|
||||
method: "GET",
|
||||
url: "/api/agents/me",
|
||||
auth: "Bearer real-run-jwt",
|
||||
runId: "run-bridge-local",
|
||||
}]);
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an explicit hostApiUrl input override the bridge forward target", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-override-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "claude");
|
||||
await mkdir(runtimeRootDir, { recursive: true });
|
||||
|
||||
const requests: string[] = [];
|
||||
const apiServer = createServer((req, res) => {
|
||||
requests.push(req.url ?? "/");
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
apiServer.once("error", reject);
|
||||
apiServer.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = apiServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected the bridge override test API server to listen on a TCP port.");
|
||||
}
|
||||
|
||||
// Neither the public URL envs nor the listen host/port should matter when
|
||||
// the caller passes an explicit hostApiUrl.
|
||||
vi.stubEnv("PAPERCLIP_RUNTIME_API_URL", "https://public.example.invalid");
|
||||
vi.stubEnv("PAPERCLIP_API_URL", "https://public.example.invalid");
|
||||
vi.stubEnv("PAPERCLIP_LISTEN_HOST", "203.0.113.1");
|
||||
vi.stubEnv("PAPERCLIP_LISTEN_PORT", "9");
|
||||
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd,
|
||||
runner: createLocalSandboxRunner(),
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId: "run-bridge-override",
|
||||
target,
|
||||
runtimeRootDir,
|
||||
adapterKey: "claude",
|
||||
hostApiToken: "real-run-jwt",
|
||||
hostApiUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
try {
|
||||
expect(bridge).not.toBeNull();
|
||||
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(requests).toEqual(["/api/agents/me"]);
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,623 @@
|
||||
import { execFile as execFileCallback, spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getProcessSessionRemoteSource,
|
||||
startAdapterExecutionTargetProcessSessionBridge,
|
||||
type AdapterSandboxExecutionTarget,
|
||||
} from "./execution-target.js";
|
||||
import { createCommandManagedSandboxCallbackBridgeQueueClient } from "./sandbox-callback-bridge.js";
|
||||
import { runChildProcess, type RunProcessResult } from "./server-utils.js";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
// Regression coverage for the stdin file race (parent PAP-4037): the host sends
|
||||
// each ACP message as a file in the sandbox stdin directory, and a poller in
|
||||
// the sandbox reads the file and writes the data to the child. Two defects lost
|
||||
// a message. The host write was not atomic, so the poller could read an empty
|
||||
// or partial `.json` file. The poller deleted the file before it validated the
|
||||
// content, so an empty read was lost and a partial read stopped the loop.
|
||||
describe("stdin file race (parent PAP-4037)", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanupDirs.length > 0) {
|
||||
const dir = cleanupDirs.pop();
|
||||
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Poller wrapper harness -------------------------------------------
|
||||
|
||||
type DeliveredFrame = { seq: number; type: string; stream?: string; data?: string; message?: string };
|
||||
|
||||
// Run the real emitted poller wrapper as a node process. The streamed variant
|
||||
// writes one JSON frame per line to its stdout, so the test reads the frames
|
||||
// directly. The child command is `cat`, so every byte the poller writes to
|
||||
// the child stdin comes back as a `data` frame.
|
||||
async function startPollerWrapper(options?: { maxRetries?: number }) {
|
||||
const sessionDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-poll-"));
|
||||
cleanupDirs.push(sessionDir);
|
||||
const stdinDir = path.join(sessionDir, "stdin");
|
||||
await mkdir(stdinDir, { recursive: true });
|
||||
|
||||
const wrapperPath = path.join(sessionDir, "wrapper.mjs");
|
||||
await writeFile(wrapperPath, getProcessSessionRemoteSource({ outputToStdout: true }), "utf8");
|
||||
|
||||
const config = { command: "cat", args: [] as string[], cwd: sessionDir, env: {} };
|
||||
const commandPayload = Buffer.from(JSON.stringify(config), "utf8").toString("base64");
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
|
||||
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: commandPayload,
|
||||
};
|
||||
if (options?.maxRetries != null) {
|
||||
env.PAPERCLIP_PROCESS_SESSION_STDIN_MAX_RETRIES = String(options.maxRetries);
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, [wrapperPath], {
|
||||
cwd: sessionDir,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const frames: DeliveredFrame[] = [];
|
||||
let stdoutBuffer = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString("utf8");
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
stdoutBuffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
frames.push(JSON.parse(line) as DeliveredFrame);
|
||||
}
|
||||
});
|
||||
|
||||
const exited = new Promise<void>((resolve) => child.on("close", () => resolve()));
|
||||
|
||||
return {
|
||||
sessionDir,
|
||||
stdinDir,
|
||||
frames,
|
||||
// Write a complete stdin file with an atomic rename, so the test never
|
||||
// creates its own partial-write race.
|
||||
writeFileAtomic: async (name: string, content: string) => {
|
||||
const finalPath = path.join(stdinDir, name);
|
||||
const tempPath = `${finalPath}.writing`;
|
||||
await writeFile(tempPath, content, "utf8");
|
||||
await rename(tempPath, finalPath);
|
||||
},
|
||||
// Write a `.json` file directly, so a reader can observe it before the
|
||||
// content lands. This simulates the non-atomic-write window.
|
||||
writeFileRaw: async (name: string, content: string) => {
|
||||
await writeFile(path.join(stdinDir, name), content, "utf8");
|
||||
},
|
||||
exited,
|
||||
kill: () => child.kill("SIGKILL"),
|
||||
};
|
||||
}
|
||||
|
||||
function stdinMessage(text: string): string {
|
||||
return `${JSON.stringify({ type: "stdin", data: Buffer.from(text, "utf8").toString("base64") })}\n`;
|
||||
}
|
||||
|
||||
const stdinEndMessage = `${JSON.stringify({ type: "stdinEnd" })}\n`;
|
||||
|
||||
// Concatenate every stdout `data` frame and decode it back to text.
|
||||
function collectDelivered(frames: DeliveredFrame[]): string {
|
||||
return frames
|
||||
.filter((frame) => frame.type === "data" && frame.stream === "stdout" && typeof frame.data === "string")
|
||||
.map((frame) => Buffer.from(frame.data as string, "base64").toString("utf8"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitFor(check: () => boolean, timeoutMs = 4_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (check()) return;
|
||||
await delay(20);
|
||||
}
|
||||
throw new Error("Timed out waiting for condition.");
|
||||
}
|
||||
|
||||
// ---- Poller tests -----------------------------------------------------
|
||||
|
||||
it("delivers a stdin file that appears empty first and then gets content", async () => {
|
||||
const poller = await startPollerWrapper();
|
||||
|
||||
// The file appears empty first (the non-atomic-write window). The poller
|
||||
// must keep it and retry, not delete it and lose the message.
|
||||
await poller.writeFileRaw("000000000001.json", "");
|
||||
await delay(200);
|
||||
// The poller keeps the empty file for a later retry. A poller that deletes
|
||||
// before it validates would drop the file here and lose the message.
|
||||
const afterEmpty = await readdir(poller.stdinDir);
|
||||
expect(afterEmpty).toContain("000000000001.json");
|
||||
|
||||
// The content lands in the same file. The poller must deliver it on a later
|
||||
// cycle, because it kept the file across the empty read.
|
||||
await poller.writeFileAtomic("000000000001.json", stdinMessage("late-payload"));
|
||||
|
||||
await waitFor(() => collectDelivered(poller.frames).includes("late-payload"));
|
||||
|
||||
await poller.writeFileAtomic("000000000002.json", stdinEndMessage);
|
||||
await poller.exited;
|
||||
|
||||
expect(collectDelivered(poller.frames)).toBe("late-payload");
|
||||
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps polling after a malformed file and still delivers a later valid file", async () => {
|
||||
const poller = await startPollerWrapper({ maxRetries: 3 });
|
||||
|
||||
// A malformed file sorts before the valid file. The poller keeps the send
|
||||
// order: it holds the later file until it drops the malformed file after the
|
||||
// retry limit, then it delivers the later valid file. So one bad file blocks
|
||||
// the loop only until the retry limit, not forever.
|
||||
await poller.writeFileRaw("000000000001.json", "{ this is not valid json");
|
||||
await poller.writeFileAtomic("000000000002.json", stdinMessage("valid-after-bad"));
|
||||
|
||||
await waitFor(() => collectDelivered(poller.frames).includes("valid-after-bad"));
|
||||
|
||||
await poller.writeFileAtomic("000000000003.json", stdinEndMessage);
|
||||
await poller.exited;
|
||||
|
||||
expect(collectDelivered(poller.frames)).toBe("valid-after-bad");
|
||||
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not close the stream on a later stdinEnd while an earlier file awaits retry", async () => {
|
||||
const poller = await startPollerWrapper();
|
||||
|
||||
// An earlier stdin file is momentarily unreadable (the non-atomic-write
|
||||
// window). A later stdinEnd file is already complete. The poller must keep
|
||||
// the send order: it must not read the stdinEnd ahead of the earlier file
|
||||
// and close the stream. It must hold the stream open until the earlier file
|
||||
// is readable.
|
||||
await poller.writeFileRaw("000000000001.json", "");
|
||||
await poller.writeFileAtomic("000000000002.json", stdinEndMessage);
|
||||
|
||||
// Give the poller time to scan. The stream stays open, so the child does not
|
||||
// exit and no exit frame appears yet.
|
||||
await delay(300);
|
||||
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(false);
|
||||
|
||||
// The earlier file's content lands. The poller delivers it, then reads the
|
||||
// stdinEnd and closes the stream.
|
||||
await poller.writeFileAtomic("000000000001.json", stdinMessage("early-payload"));
|
||||
|
||||
await waitFor(() => collectDelivered(poller.frames).includes("early-payload"));
|
||||
await poller.exited;
|
||||
|
||||
expect(collectDelivered(poller.frames)).toBe("early-payload");
|
||||
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a permanently malformed file after the retry limit and writes an error event", async () => {
|
||||
const poller = await startPollerWrapper({ maxRetries: 3 });
|
||||
|
||||
// This file never becomes valid. After the retry limit the poller drops it
|
||||
// and writes an error event, so the lost message fails loudly.
|
||||
await poller.writeFileRaw("000000000001.json", "{ permanently broken");
|
||||
|
||||
await waitFor(() =>
|
||||
poller.frames.some(
|
||||
(frame) => frame.type === "error" && typeof frame.message === "string" && frame.message.includes("Dropped unreadable stdin file"),
|
||||
),
|
||||
);
|
||||
|
||||
// The loop still works after the drop: a later valid file is delivered.
|
||||
await poller.writeFileAtomic("000000000002.json", stdinMessage("still-alive"));
|
||||
await waitFor(() => collectDelivered(poller.frames).includes("still-alive"));
|
||||
|
||||
await poller.writeFileAtomic("000000000003.json", stdinEndMessage);
|
||||
await poller.exited;
|
||||
|
||||
expect(collectDelivered(poller.frames)).toBe("still-alive");
|
||||
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
|
||||
});
|
||||
|
||||
it("holds a later stdin file until the missing earlier file appears", async () => {
|
||||
const poller = await startPollerWrapper();
|
||||
|
||||
// File 2 is complete, but file 1 has not appeared yet (a host reordering).
|
||||
// The poller must not deliver file 2 ahead of the missing file 1. It holds
|
||||
// the send order and waits for the earlier file.
|
||||
await poller.writeFileAtomic("000000000002.json", stdinMessage("second-payload"));
|
||||
await delay(300);
|
||||
// File 2 is still on disk and nothing was delivered: the poller holds it.
|
||||
const afterHold = await readdir(poller.stdinDir);
|
||||
expect(afterHold).toContain("000000000002.json");
|
||||
expect(collectDelivered(poller.frames)).toBe("");
|
||||
|
||||
// File 1 arrives. The poller now delivers file 1 then file 2, in send order.
|
||||
await poller.writeFileAtomic("000000000001.json", stdinMessage("first-payload"));
|
||||
await waitFor(() => collectDelivered(poller.frames).includes("second-payload"));
|
||||
expect(collectDelivered(poller.frames)).toBe("first-payloadsecond-payload");
|
||||
|
||||
await poller.writeFileAtomic("000000000003.json", stdinEndMessage);
|
||||
await poller.exited;
|
||||
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
|
||||
});
|
||||
|
||||
it("fails loud and advances past a missing stdin file after the retry limit", async () => {
|
||||
const poller = await startPollerWrapper({ maxRetries: 3 });
|
||||
|
||||
// File 1 never appears. File 2 is complete. After the retry limit the poller
|
||||
// writes a loud error event and advances past the gap, then delivers file 2.
|
||||
// So a permanent reordering fails loud, never silently.
|
||||
await poller.writeFileAtomic("000000000002.json", stdinMessage("after-gap"));
|
||||
|
||||
await waitFor(() =>
|
||||
poller.frames.some(
|
||||
(frame) =>
|
||||
frame.type === "error" &&
|
||||
typeof frame.message === "string" &&
|
||||
frame.message.includes("Advanced past missing stdin files"),
|
||||
),
|
||||
);
|
||||
await waitFor(() => collectDelivered(poller.frames).includes("after-gap"));
|
||||
|
||||
await poller.writeFileAtomic("000000000003.json", stdinEndMessage);
|
||||
await poller.exited;
|
||||
expect(collectDelivered(poller.frames)).toBe("after-gap");
|
||||
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
|
||||
});
|
||||
|
||||
// ---- Host serialization test (drives the real bridge) -----------------
|
||||
|
||||
// A runner that runs each bridge shell script as a real child process, so the
|
||||
// test drives the whole legacy-poll bridge: the socket handler, the command-
|
||||
// managed `writeTextFile` script, the nohup wrapper, and the output poll.
|
||||
function createLocalSandboxRunner(
|
||||
onExecute?: (script: string) => Promise<void>,
|
||||
) {
|
||||
let counter = 0;
|
||||
return {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
}): Promise<RunProcessResult> => {
|
||||
counter += 1;
|
||||
const script = input.args?.[1] ?? "";
|
||||
if (onExecute) await onExecute(script);
|
||||
const command =
|
||||
input.command === "bash" ? "/bin/bash" : input.command === "sh" ? "/bin/sh" : input.command;
|
||||
return runChildProcess(`stdin-order-run-${counter}`, command, input.args ?? [], {
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
env: input.env ?? {},
|
||||
stdin: input.stdin,
|
||||
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
||||
graceSec: 5,
|
||||
onLog: input.onLog ?? (async () => {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("serializes host stdin writes so a slow earlier write still lands first", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-host-order-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
// The child echoes every stdin byte to stdout, so the wrapper reports the
|
||||
// exact bytes and order the child received on its stdin.
|
||||
const childPath = path.join(rootDir, "echo-child.mjs");
|
||||
await writeFile(childPath, "process.stdin.on('data', (c) => process.stdout.write(c));\n", "utf8");
|
||||
|
||||
// Record the send-order-relevant event: the completion of each stdin file's
|
||||
// finalize (atomic rename). Delay the finalize of the FIRST file, so its
|
||||
// write resolves slower than the second. Without serialization the second
|
||||
// rename would land first; the per-session chain must keep the send order.
|
||||
const finalizeOrder: string[] = [];
|
||||
const runner = createLocalSandboxRunner(async (script) => {
|
||||
const finalizeMatch = /base64 -d[\s\S]*mv '[^']*\.decoded' '([^']+\.json)'/.exec(script);
|
||||
if (finalizeMatch) {
|
||||
const remotePath = finalizeMatch[1];
|
||||
if (remotePath.endsWith("000000000001.json")) await delay(300);
|
||||
finalizeOrder.push(path.posix.basename(remotePath));
|
||||
}
|
||||
});
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stdin-host-order",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
let peer: net.Socket | null = null;
|
||||
try {
|
||||
const proxySource = await readFile(bridge!.agentCommand, "utf8");
|
||||
const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN);
|
||||
const tokenLiteral = /const token = (".*?");/.exec(proxySource)?.[1];
|
||||
expect(Number.isFinite(port)).toBe(true);
|
||||
const token = JSON.parse(tokenLiteral as string) as string;
|
||||
|
||||
const peerSocket = net.createConnection({ host: "127.0.0.1", port });
|
||||
peer = peerSocket;
|
||||
peerSocket.setEncoding("utf8");
|
||||
peerSocket.on("error", () => undefined);
|
||||
const delivered: string[] = [];
|
||||
let peerBuffer = "";
|
||||
peerSocket.on("data", (chunk: string) => {
|
||||
peerBuffer += chunk;
|
||||
const lines = peerBuffer.split("\n");
|
||||
peerBuffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const frame = JSON.parse(line) as { type?: string; stream?: string; data?: string };
|
||||
if (frame.type === "data" && frame.stream === "stdout" && typeof frame.data === "string") {
|
||||
delivered.push(Buffer.from(frame.data, "base64").toString("utf8"));
|
||||
}
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
peerSocket.once("connect", () => resolve());
|
||||
peerSocket.once("error", reject);
|
||||
});
|
||||
|
||||
// Send two stdin messages back to back. The first authenticates and writes
|
||||
// file 1; the second writes file 2. Both are scheduled before file 1's
|
||||
// delayed finalize resolves, so an un-chained handler would race them.
|
||||
const head = `${JSON.stringify({ token, type: "stdin", data: Buffer.from("HEAD_ONE_", "utf8").toString("base64") })}\n`;
|
||||
const tail = `${JSON.stringify({ token, type: "stdin", data: Buffer.from("TAIL_TWO", "utf8").toString("base64") })}\n`;
|
||||
peerSocket.write(head);
|
||||
peerSocket.write(tail);
|
||||
|
||||
// The two finalize renames complete in send order, not in the order the
|
||||
// delayed and fast writes would otherwise finish.
|
||||
await waitFor(() => finalizeOrder.length >= 2, 8_000);
|
||||
expect(finalizeOrder.slice(0, 2)).toEqual(["000000000001.json", "000000000002.json"]);
|
||||
|
||||
// End to end: the child receives the two payloads intact and in send
|
||||
// order, so the prompt is byte-identical on the child stdin.
|
||||
await waitFor(() => delivered.join("").includes("TAIL_TWO"), 8_000);
|
||||
expect(delivered.join("")).toBe("HEAD_ONE_TAIL_TWO");
|
||||
} finally {
|
||||
peer?.destroy();
|
||||
await bridge?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("holds stdinEnd on stop until an earlier pending stdin write lands first", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-stop-order-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "echo-child.mjs");
|
||||
await writeFile(childPath, "process.stdin.on('data', (c) => process.stdout.write(c));\n", "utf8");
|
||||
|
||||
// Record each stdin file finalize (atomic rename). `finalizeStarted` marks
|
||||
// the start; `finalizeOrder` marks the completion. Delay the FIRST chunk's
|
||||
// finalize, so its write is still pending when `stop()` runs. `stop()` must
|
||||
// chain the `stdinEnd` write after the pending chunk, so file 2 (stdinEnd)
|
||||
// never finishes its rename before file 1.
|
||||
const finalizeStarted: string[] = [];
|
||||
const finalizeOrder: string[] = [];
|
||||
const runner = createLocalSandboxRunner(async (script) => {
|
||||
const finalizeMatch = /base64 -d[\s\S]*mv '[^']*\.decoded' '([^']+\.json)'/.exec(script);
|
||||
if (finalizeMatch) {
|
||||
const name = path.posix.basename(finalizeMatch[1]);
|
||||
finalizeStarted.push(name);
|
||||
if (name === "000000000001.json") await delay(300);
|
||||
finalizeOrder.push(name);
|
||||
}
|
||||
});
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "run-stdin-stop-order",
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: {},
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
let peer: net.Socket | null = null;
|
||||
let stopped = false;
|
||||
try {
|
||||
const proxySource = await readFile(bridge!.agentCommand, "utf8");
|
||||
const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN);
|
||||
const tokenLiteral = /const token = (".*?");/.exec(proxySource)?.[1];
|
||||
expect(Number.isFinite(port)).toBe(true);
|
||||
const token = JSON.parse(tokenLiteral as string) as string;
|
||||
|
||||
const peerSocket = net.createConnection({ host: "127.0.0.1", port });
|
||||
peer = peerSocket;
|
||||
peerSocket.setEncoding("utf8");
|
||||
peerSocket.on("error", () => undefined);
|
||||
peerSocket.on("data", () => undefined);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
peerSocket.once("connect", () => resolve());
|
||||
peerSocket.once("error", reject);
|
||||
});
|
||||
|
||||
// Send one stdin message. It authenticates and writes file 1, whose
|
||||
// finalize the runner delays. Wait until that finalize has started, so the
|
||||
// write is in flight when `stop()` runs.
|
||||
const head = `${JSON.stringify({ token, type: "stdin", data: Buffer.from("HEAD_ONE_", "utf8").toString("base64") })}\n`;
|
||||
peerSocket.write(head);
|
||||
await waitFor(() => finalizeStarted.includes("000000000001.json"), 8_000);
|
||||
|
||||
// Stop the bridge while file 1's write is still pending. `stop()` awaits
|
||||
// the chained `stdinEnd` write, so both finalizes are complete when it
|
||||
// returns, in send order.
|
||||
await bridge!.stop();
|
||||
stopped = true;
|
||||
expect(finalizeOrder).toEqual(["000000000001.json", "000000000002.json"]);
|
||||
} finally {
|
||||
peer?.destroy();
|
||||
if (!stopped) await bridge?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Host atomic-write tests ------------------------------------------
|
||||
|
||||
// A runner that executes each bridge shell script on the local filesystem,
|
||||
// so the test exercises the real command-managed `writeTextFile` script.
|
||||
function createLocalShellRunner(scripts: string[]) {
|
||||
return {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<RunProcessResult> => {
|
||||
const args = input.args ?? [];
|
||||
if ((input.command === "sh" || input.command === "bash") && args[0] === "-c" && typeof args[1] === "string") {
|
||||
scripts.push(args[1]);
|
||||
}
|
||||
const command = input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
|
||||
try {
|
||||
const result = await execFile(command, args, {
|
||||
cwd: input.cwd,
|
||||
env: { ...process.env, ...input.env },
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
pid: null,
|
||||
startedAt: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: string | number | null };
|
||||
return {
|
||||
exitCode: typeof err.code === "number" ? err.code : null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: err.stdout ?? "",
|
||||
stderr: err.stderr ?? "",
|
||||
pid: null,
|
||||
startedAt: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("finalizes the command-managed host write with an atomic rename onto the .json path", async () => {
|
||||
const remoteRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-host-cmd-"));
|
||||
cleanupDirs.push(remoteRoot);
|
||||
const stdinDir = path.join(remoteRoot, "stdin");
|
||||
await mkdir(stdinDir, { recursive: true });
|
||||
|
||||
const scripts: string[] = [];
|
||||
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
|
||||
runner: createLocalShellRunner(scripts),
|
||||
remoteCwd: remoteRoot,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const jsonPath = path.join(stdinDir, "000000000001.json");
|
||||
const body = `${JSON.stringify({ type: "stdin", data: Buffer.from("host-payload", "utf8").toString("base64") })}\n`;
|
||||
await client.writeTextFile(jsonPath, body);
|
||||
|
||||
// The final file holds the complete body.
|
||||
expect(await readFile(jsonPath, "utf8")).toBe(body);
|
||||
// No temporary upload file remains next to the final file.
|
||||
const entries = await readdir(stdinDir);
|
||||
expect(entries).toEqual(["000000000001.json"]);
|
||||
|
||||
// The finalize script renames a non-`.json` temporary file onto the final
|
||||
// path. It never redirects the decode output straight into the `.json`
|
||||
// file, so a reader never sees an empty or partial `.json` file.
|
||||
const finalizeScript = scripts.find((script) => script.includes("base64 -d"));
|
||||
expect(finalizeScript).toBeDefined();
|
||||
expect(finalizeScript).toContain(`mv `);
|
||||
expect(finalizeScript).not.toContain(`> '${jsonPath}'`);
|
||||
expect(finalizeScript).toContain(`> '${jsonPath}.paperclip-upload.decoded'`);
|
||||
});
|
||||
|
||||
it("never exposes a partial .json file under a concurrent reader (command-managed host write)", async () => {
|
||||
const remoteRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-host-race-"));
|
||||
cleanupDirs.push(remoteRoot);
|
||||
const stdinDir = path.join(remoteRoot, "stdin");
|
||||
await mkdir(stdinDir, { recursive: true });
|
||||
|
||||
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
|
||||
runner: createLocalShellRunner([]),
|
||||
remoteCwd: remoteRoot,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const jsonPath = path.join(stdinDir, "000000000001.json");
|
||||
// A large body needs many decode bytes, so the write window is wide.
|
||||
const bigText = "x".repeat(64 * 1024);
|
||||
const body = `${JSON.stringify({ type: "stdin", data: Buffer.from(bigText, "utf8").toString("base64") })}\n`;
|
||||
|
||||
let stop = false;
|
||||
const readerErrors: string[] = [];
|
||||
let observedComplete = 0;
|
||||
const reader = (async () => {
|
||||
while (!stop) {
|
||||
const raw = await readFile(jsonPath, "utf8").catch(() => null);
|
||||
if (raw) {
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
observedComplete += 1;
|
||||
} catch (error) {
|
||||
readerErrors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
for (let round = 0; round < 6; round += 1) {
|
||||
await client.remove(jsonPath);
|
||||
await client.writeTextFile(jsonPath, body);
|
||||
}
|
||||
stop = true;
|
||||
await reader;
|
||||
|
||||
// Every read of the final file parsed as complete JSON. The reader never
|
||||
// saw an empty or partial `.json` file.
|
||||
expect(readerErrors).toEqual([]);
|
||||
expect(observedComplete).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,18 @@ import type { LocalProcessSandboxOptions } from "./local-process-sandbox.js";
|
||||
|
||||
export type { RuntimeProgressSink } from "./runtime-progress.js";
|
||||
|
||||
export function postedIssueCommentLogMarker(method: string, requestPath: string, status: number, body: string) {
|
||||
if (method !== "POST" || !/^\/api\/issues\/[^/]+\/comments$/.test(requestPath) || status < 200 || status >= 300) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { id?: unknown };
|
||||
return typeof parsed.id === "string" && parsed.id.length > 0 ? `comment id: ${parsed.id}\n` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type AdapterWorkspaceRealizationMode = "copy" | "in_place";
|
||||
|
||||
export interface AdapterWorkspacePathAlias {
|
||||
@@ -89,10 +101,32 @@ export interface AdapterSshExecutionTarget extends AdapterExecutionTargetWorkspa
|
||||
spec: SshRemoteExecutionSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only snapshot of the effective sandbox capabilities for one execution
|
||||
* target. Each flag is the resolved result of the provider's declaration, the
|
||||
* live worker's verified methods, and any narrowing from the config or lease.
|
||||
* The host computes it once and attaches it to the target; a consumer reads it
|
||||
* but never changes it, so every field is `readonly`.
|
||||
*/
|
||||
export interface EffectiveSandboxCapabilities {
|
||||
readonly reusableLeases: boolean;
|
||||
readonly nativeSyncIn: boolean;
|
||||
readonly nativeSyncOut: boolean;
|
||||
readonly persistentProcessSessions: boolean;
|
||||
readonly independentControlCommands: boolean;
|
||||
readonly incrementalSessionOutput: boolean;
|
||||
}
|
||||
|
||||
export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata {
|
||||
kind: "remote";
|
||||
transport: "sandbox";
|
||||
providerKey?: string | null;
|
||||
/**
|
||||
* Read-only effective capability snapshot for this sandbox target. The host
|
||||
* resolves it from the provider declaration ∩ the verified worker methods ∩
|
||||
* narrowing, then attaches it here. Absent when no snapshot was resolved.
|
||||
*/
|
||||
readonly effectiveCapabilities?: EffectiveSandboxCapabilities | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
environmentId?: string | null;
|
||||
leaseId?: string | null;
|
||||
@@ -206,13 +240,35 @@ function readString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
// Read a serialized effective-capability snapshot back into a full record. A
|
||||
// missing or non-boolean field reads as `false`, so a round-tripped target
|
||||
// never grants a capability that the snapshot did not carry. Returns null when
|
||||
// there is no object to read.
|
||||
function parseEffectiveSandboxCapabilities(value: unknown): EffectiveSandboxCapabilities | null {
|
||||
const parsed = parseObject(value);
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
return {
|
||||
reusableLeases: parsed.reusableLeases === true,
|
||||
nativeSyncIn: parsed.nativeSyncIn === true,
|
||||
nativeSyncOut: parsed.nativeSyncOut === true,
|
||||
persistentProcessSessions: parsed.persistentProcessSessions === true,
|
||||
independentControlCommands: parsed.independentControlCommands === true,
|
||||
incrementalSessionOutput: parsed.incrementalSessionOutput === true,
|
||||
};
|
||||
}
|
||||
|
||||
function readStringMeta(parsed: Record<string, unknown>, key: string): string | null {
|
||||
return readString(parsed[key]);
|
||||
}
|
||||
|
||||
function resolveHostForUrl(rawHost: string): string {
|
||||
const host = rawHost.trim();
|
||||
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
|
||||
// Preserve the wildcard bind's address family: a server bound to 0.0.0.0
|
||||
// accepts IPv4, so target the IPv4 loopback (and [::1] for ::) instead of
|
||||
// "localhost", which the resolver may map to the other family.
|
||||
if (host === "0.0.0.0") return "127.0.0.1";
|
||||
if (host === "::") return "[::1]";
|
||||
if (!host) return "localhost";
|
||||
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
|
||||
return host;
|
||||
}
|
||||
@@ -1073,6 +1129,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
|
||||
if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") {
|
||||
const remoteCwd = readStringMeta(parsed, "remoteCwd");
|
||||
if (!remoteCwd) return null;
|
||||
const effectiveCapabilities = parseEffectiveSandboxCapabilities(parsed.effectiveCapabilities);
|
||||
return {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
@@ -1082,6 +1139,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
|
||||
remoteCwd,
|
||||
timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null,
|
||||
streamRunLogs: typeof parsed.streamRunLogs === "boolean" ? parsed.streamRunLogs : null,
|
||||
...(effectiveCapabilities ? { effectiveCapabilities } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1143,6 +1201,11 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
|
||||
// counters without further changes here.
|
||||
onProgress?: RuntimeProgressSink;
|
||||
onRuntimeProgress?: RuntimeStatusSink;
|
||||
// Optional host span runner for the workspace tarball build. Only the confined
|
||||
// sandbox lane uses it: it forwards the runner to prepareCommandManagedRuntime
|
||||
// so the host pack time rides one `pack` span under the `stage.sync` step. The
|
||||
// SSH and local lanes ignore it. The default is a no-op.
|
||||
runtimeSpan?: RuntimeSpanRunner;
|
||||
}): Promise<PreparedAdapterExecutionTargetRuntime> {
|
||||
const target = input.target ?? { kind: "local" as const };
|
||||
if (target.kind === "local") {
|
||||
@@ -1206,6 +1269,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
|
||||
detectCommand: input.detectCommand,
|
||||
onProgress: input.onProgress,
|
||||
onRuntimeProgress: input.onRuntimeProgress,
|
||||
runtimeSpan: input.runtimeSpan,
|
||||
});
|
||||
return {
|
||||
target,
|
||||
@@ -1228,7 +1292,12 @@ export function runtimeAssetDir(
|
||||
|
||||
function buildBridgeResponseHeaders(response: Response): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of ["content-type", "etag", "last-modified"]) {
|
||||
// Keep `x-paperclip-bridge-outcome` in this list. The host marks a
|
||||
// possibly-committed mutation with the `indeterminate` outcome. The in-sandbox
|
||||
// server reads that header to map the 504 to a terminal 409. If the forward
|
||||
// drops the header, the server keeps the retryable 504 and a caller that
|
||||
// retries 5xx can repeat a mutation that already committed.
|
||||
for (const key of ["content-type", "etag", "last-modified", "x-paperclip-bridge-outcome"]) {
|
||||
const value = response.headers.get(key);
|
||||
if (value && value.trim().length > 0) out[key] = value.trim();
|
||||
}
|
||||
@@ -1278,6 +1347,10 @@ async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: n
|
||||
|
||||
const PROCESS_SESSION_PROXY_SCRIPT = "paperclip-process-session-proxy.mjs";
|
||||
const PROCESS_SESSION_REMOTE_SCRIPT = "paperclip-process-session-remote.mjs";
|
||||
// The streamed variant writes its output frames to stdout, so it rides a
|
||||
// separate remote path. A sandbox can hold both scripts without the content
|
||||
// hash-skip gate thrashing when a run switches output mode.
|
||||
const PROCESS_SESSION_REMOTE_STREAM_SCRIPT = "paperclip-process-session-remote-stream.mjs";
|
||||
const PROCESS_SESSION_AUTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
function jsonLine(value: unknown): string {
|
||||
@@ -1309,13 +1382,14 @@ async function syncProcessSessionRemoteScript(input: {
|
||||
remoteScriptPath: string;
|
||||
timeoutMs?: number | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
outputToStdout?: boolean;
|
||||
}): Promise<{ uploaded: boolean }> {
|
||||
const { uploaded } = await syncRemoteTextFileWithHashSkip({
|
||||
runner: input.runner,
|
||||
remoteCwd: input.remoteCwd,
|
||||
remoteDir: input.remoteScriptDir,
|
||||
remotePath: input.remoteScriptPath,
|
||||
body: getProcessSessionRemoteSource(),
|
||||
body: getProcessSessionRemoteSource({ outputToStdout: input.outputToStdout === true }),
|
||||
label: "Process session remote script",
|
||||
action: "sync process session remote script",
|
||||
lockDir: path.posix.join(input.remoteScriptDir, ".paperclip-process-session-script.lock"),
|
||||
@@ -1391,6 +1465,11 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
// group under one wrapper span. When it is absent, the work runs under the run
|
||||
// parent with no wrapper span, exactly like the earlier behavior.
|
||||
runtimeSpan?: RuntimeSpanRunner;
|
||||
// Stream the agent output through the persistent session log stream instead of
|
||||
// the host output-file poll. When true, the bridge runs the wrapper as one
|
||||
// long-lived session command and reads its stdout frames from the stream, and
|
||||
// it does not start the 100 ms poll. Default OFF: the bridge keeps the poll.
|
||||
streamOutputViaSession?: boolean;
|
||||
}): Promise<AdapterExecutionTargetProcessSessionBridgeHandle | null> {
|
||||
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
|
||||
return null;
|
||||
@@ -1420,7 +1499,14 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
const sessionDir = path.posix.join(bridgeRuntimeDir, sessionId);
|
||||
const stdinDir = path.posix.join(sessionDir, "stdin");
|
||||
const eventsDir = path.posix.join(sessionDir, "events");
|
||||
const remoteScriptPath = path.posix.join(bridgeRuntimeDir, PROCESS_SESSION_REMOTE_SCRIPT);
|
||||
// The streamed wrapper writes its frames to stdout and rides a separate remote
|
||||
// path, so a warm sandbox can hold both wrapper scripts without the content
|
||||
// hash-skip gate thrashing when a run switches output mode.
|
||||
const streamOutput = input.streamOutputViaSession === true;
|
||||
const remoteScriptPath = path.posix.join(
|
||||
bridgeRuntimeDir,
|
||||
streamOutput ? PROCESS_SESSION_REMOTE_STREAM_SCRIPT : PROCESS_SESSION_REMOTE_SCRIPT,
|
||||
);
|
||||
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
|
||||
runner,
|
||||
remoteCwd: target.remoteCwd,
|
||||
@@ -1438,6 +1524,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
remoteScriptPath,
|
||||
timeoutMs,
|
||||
shellCommand,
|
||||
outputToStdout: streamOutput,
|
||||
});
|
||||
|
||||
// Resolve the launch env AFTER the env-independent setup above, so a caller
|
||||
@@ -1451,31 +1538,54 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
env: sanitizeRemoteExecutionEnv(launchEnv),
|
||||
}), "utf8").toString("base64");
|
||||
|
||||
await onLog("stdout", `[paperclip] Starting ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`);
|
||||
const startResult = await runner.execute({
|
||||
command: shellCommand,
|
||||
args: shellCommandArgs(
|
||||
[
|
||||
`mkdir -p ${shellQuote(stdinDir)} ${shellQuote(eventsDir)}`,
|
||||
`PAPERCLIP_PROCESS_SESSION_DIR=${shellQuote(sessionDir)} ` +
|
||||
`PAPERCLIP_PROCESS_SESSION_COMMAND_B64=${shellQuote(commandPayload)} ` +
|
||||
`nohup node ${shellQuote(remoteScriptPath)} >/dev/null 2>&1 < /dev/null &`,
|
||||
"printf '%s\\n' \"$!\"",
|
||||
].join("\n"),
|
||||
),
|
||||
cwd: target.remoteCwd,
|
||||
env: {
|
||||
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
|
||||
},
|
||||
timeoutMs,
|
||||
});
|
||||
if (startResult.timedOut || (startResult.exitCode ?? 1) !== 0) {
|
||||
throw new Error(`Failed to start sandbox ACP process session bridge: ${startResult.stderr || startResult.stdout}`);
|
||||
// Legacy poll path: background the wrapper with `nohup` and read its output
|
||||
// event files with the host poll below. The streamed path launches the wrapper
|
||||
// as one foreground session command further down instead, so skip this.
|
||||
if (!streamOutput) {
|
||||
await onLog("stdout", `[paperclip] Starting ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`);
|
||||
const startResult = await runner.execute({
|
||||
command: shellCommand,
|
||||
args: shellCommandArgs(
|
||||
[
|
||||
`mkdir -p ${shellQuote(stdinDir)} ${shellQuote(eventsDir)}`,
|
||||
`PAPERCLIP_PROCESS_SESSION_DIR=${shellQuote(sessionDir)} ` +
|
||||
`PAPERCLIP_PROCESS_SESSION_COMMAND_B64=${shellQuote(commandPayload)} ` +
|
||||
`nohup node ${shellQuote(remoteScriptPath)} >/dev/null 2>&1 < /dev/null &`,
|
||||
"printf '%s\\n' \"$!\"",
|
||||
].join("\n"),
|
||||
),
|
||||
cwd: target.remoteCwd,
|
||||
env: {
|
||||
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
|
||||
},
|
||||
timeoutMs,
|
||||
// The wrapper launch is bridge plumbing. Keep it off the persistent
|
||||
// session so it never queues behind an in-run session command.
|
||||
bypassSession: true,
|
||||
});
|
||||
if (startResult.timedOut || (startResult.exitCode ?? 1) !== 0) {
|
||||
throw new Error(`Failed to start sandbox ACP process session bridge: ${startResult.stderr || startResult.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
let socket: net.Socket | null = null;
|
||||
let stopping = false;
|
||||
// Resolves when `stop()` tears the bridge down. The streamed `sandbox.agentProcess`
|
||||
// span races its work against this, so the span ends at teardown at the latest
|
||||
// even when the remote process lingers, and never outlives the run root span.
|
||||
let signalStopped: () => void = () => {};
|
||||
const stopped = new Promise<void>((resolve) => {
|
||||
signalStopped = resolve;
|
||||
});
|
||||
let stdinSeq = 0;
|
||||
// One promise chain per session that serializes the stdin file writes. Each
|
||||
// write is multi-exec on the command-managed client: prepare, append per 32
|
||||
// KiB, then an atomic rename. The chain makes the rename for file N finish
|
||||
// before the write for file N+1 starts, so the files land in send order.
|
||||
// Without it the writes overlap. A small later chunk can then rename ahead of
|
||||
// a big earlier chunk, so the wrapper reads the stdin bytes out of order and
|
||||
// corrupts a large prompt on the stdin path.
|
||||
let stdinWriteChain: Promise<void> = Promise.resolve();
|
||||
let pollTimer: NodeJS.Timeout | null = null;
|
||||
const pendingRemoteEvents: Array<{
|
||||
type?: string;
|
||||
@@ -1586,9 +1696,21 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
if (stdinPayload) {
|
||||
stdinSeq += 1;
|
||||
const name = `${String(stdinSeq).padStart(12, "0")}.json`;
|
||||
void runRuntimeWork(AGENT_SESSION_SEND_INPUT_SPAN, () =>
|
||||
client.writeTextFile(path.posix.join(stdinDir, name), jsonLine(stdinPayload)),
|
||||
).catch((error) => {
|
||||
const filePath = path.posix.join(stdinDir, name);
|
||||
// Chain this write after the previous one, so the atomic rename for
|
||||
// file N finishes before the write for file N+1 starts. Keep the
|
||||
// per-message `sandbox.agentSession.sendInput` span inside the chain.
|
||||
const write = stdinWriteChain.then(() =>
|
||||
runRuntimeWork(AGENT_SESSION_SEND_INPUT_SPAN, () =>
|
||||
client.writeTextFile(filePath, jsonLine(stdinPayload)),
|
||||
),
|
||||
);
|
||||
// The next message chains after this write on success or failure, so a
|
||||
// failed write never blocks the chain. This mirrors the wrapper
|
||||
// `writeChain` pattern for its event files.
|
||||
stdinWriteChain = write.then(() => undefined, () => undefined);
|
||||
// Keep the failure behavior: send one error line, then destroy the socket.
|
||||
write.catch((error) => {
|
||||
nextSocket.write(jsonLine({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
||||
nextSocket.destroy();
|
||||
});
|
||||
@@ -1640,19 +1762,155 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
|
||||
const port = await waitForLocalServerListen(server);
|
||||
const agentCommand = await writeProcessSessionProxyScript(proxyDir, port, token);
|
||||
schedulePoll();
|
||||
|
||||
if (streamOutput) {
|
||||
// Streamed output path. Run the wrapper as one long-lived session command;
|
||||
// its stdout carries newline-delimited JSON frames that reach the host
|
||||
// through the provider session log stream. Deliver each frame exactly once
|
||||
// by its monotonic `seq`, so a frame that arrives both live and in the final
|
||||
// result is not repeated. There is no host output-file poll here.
|
||||
let streamBuffer = "";
|
||||
let lastSeq = 0;
|
||||
let sawTerminal = false;
|
||||
const deliverFrame = (frame: (typeof pendingRemoteEvents)[number] & { seq?: number }) => {
|
||||
if (typeof frame.seq === "number") {
|
||||
if (frame.seq <= lastSeq) return;
|
||||
lastSeq = frame.seq;
|
||||
}
|
||||
if (frame.type === "exit" || frame.type === "error") sawTerminal = true;
|
||||
deliverRemoteEvent(frame);
|
||||
};
|
||||
const parseFrameLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let frame: (typeof pendingRemoteEvents)[number] & { seq?: number };
|
||||
try {
|
||||
frame = JSON.parse(line) as typeof frame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
deliverFrame(frame);
|
||||
};
|
||||
// Live delivery: buffer partial lines across stream chunks, deliver each
|
||||
// complete frame line as it arrives.
|
||||
const ingestStreamChunk = (text: string) => {
|
||||
streamBuffer += text;
|
||||
const split = splitJsonLines(streamBuffer);
|
||||
streamBuffer = split.rest;
|
||||
for (const line of split.lines) parseFrameLine(line);
|
||||
};
|
||||
// Terminal delivery (the defined fallback to the poll): the resolved result
|
||||
// carries the full wrapper stdout even when the live stream degraded to the
|
||||
// provider session-log poll. The text is complete and self-contained, so
|
||||
// re-parse it on its own; the `seq` guard drops every frame the live stream
|
||||
// already delivered. Drop any partial live line — its complete form is in the
|
||||
// full text.
|
||||
const ingestFinalText = (text: string) => {
|
||||
streamBuffer = "";
|
||||
for (const line of text.split(/\n/)) parseFrameLine(line);
|
||||
};
|
||||
|
||||
const launchEnvForStream =
|
||||
typeof input.env === "function" ? await input.env() : input.env;
|
||||
const streamCommandPayload = Buffer.from(JSON.stringify({
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd || target.remoteCwd,
|
||||
env: sanitizeRemoteExecutionEnv(launchEnvForStream),
|
||||
}), "utf8").toString("base64");
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Starting streamed ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`,
|
||||
);
|
||||
// Fire the long-lived command; do NOT await it here. `useSession` forces the
|
||||
// persistent session so the provider streams the wrapper stdout back through
|
||||
// `onLog`. On resolve, the terminal re-parse fills any frames the live stream
|
||||
// missed; on reject, deliver one error frame so the local proxy fails loud.
|
||||
//
|
||||
// Wrap the launch in a `sandbox.agentProcess` span. `runRuntimeWork` parents
|
||||
// it to the LIVE RUN root (`task.run` at launch time — no turn has started
|
||||
// yet), not to the ephemeral `bridge.process-session` bring-up step, and it
|
||||
// stays open for the whole process lifetime. The inner `sandbox.exec` nests
|
||||
// under it. Without the wrapper the raw exec's span inherits the ~2.28s
|
||||
// bring-up step as its parent and then dangles ~50s past it, overlapping
|
||||
// `agent.turn` — a child outliving its parent. As a run-scoped span it reads
|
||||
// instead as a resource that OVERLAPS the sibling `agent.turn`, which is the
|
||||
// correct shape (the persistent process hosts the turn; it is not a child of
|
||||
// it, and on multi-turn runs one process spans several turns). `runRuntimeWork`
|
||||
// is voided, not awaited, so bring-up never blocks on the long-lived command,
|
||||
// and it defaults to a no-op parent when no span runner is injected.
|
||||
//
|
||||
// The span is bounded to the bridge lifecycle: it ends when the command
|
||||
// settles OR when `stop()` runs, whichever comes first. `stop()` runs during
|
||||
// run teardown, before the caller ends the `task.run` root span, so the span
|
||||
// never outlives the run root even if the remote process lingers past
|
||||
// teardown (`execute` has no cancel, so a lingering process cannot be forced
|
||||
// to resolve). The command promise keeps running after the span ends so its
|
||||
// frame handlers still deliver; they no-op once `stopping` is set.
|
||||
void runRuntimeWork("sandbox.agentProcess", async () => {
|
||||
const commandSettled = (async () => {
|
||||
try {
|
||||
const result = await runner.execute({
|
||||
command: shellCommand,
|
||||
args: shellCommandArgs(`node ${shellQuote(remoteScriptPath)}`),
|
||||
cwd: target.remoteCwd,
|
||||
env: {
|
||||
PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
|
||||
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: streamCommandPayload,
|
||||
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
|
||||
},
|
||||
timeoutMs,
|
||||
useSession: true,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream === "stdout") ingestStreamChunk(chunk);
|
||||
},
|
||||
});
|
||||
ingestFinalText(result.stdout);
|
||||
if (!sawTerminal && !stopping) {
|
||||
deliverRemoteEvent({
|
||||
type: "exit",
|
||||
code: typeof result.exitCode === "number" ? result.exitCode : null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!stopping) {
|
||||
deliverRemoteEvent({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
await Promise.race([commandSettled, stopped]);
|
||||
});
|
||||
} else {
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
return {
|
||||
agentCommand,
|
||||
stop: async () => {
|
||||
stopping = true;
|
||||
// End the `sandbox.agentProcess` span now, before the caller ends the run
|
||||
// root span, even if the remote command has not resolved yet.
|
||||
signalStopped();
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
for (const liveSocket of liveSockets) liveSocket.destroy();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve())).catch(() => undefined);
|
||||
await client.writeTextFile(
|
||||
path.posix.join(stdinDir, `${String(stdinSeq + 1).padStart(12, "0")}.json`),
|
||||
jsonLine({ type: "stdinEnd" }),
|
||||
).catch(() => undefined);
|
||||
// Wait for every accepted stdin write before `stdinEnd`. The socket handler
|
||||
// fires each chunk write un-awaited through `stdinWriteChain`, so an earlier
|
||||
// chunk can still be pending here. Chain the `stdinEnd` write onto the same
|
||||
// per-session chain, so its file rename never finishes before an earlier
|
||||
// chunk. `stdinSeq` is stable now, because the sockets are destroyed and the
|
||||
// server is closed, so no new message can increment it.
|
||||
const stdinEndPath = path.posix.join(
|
||||
stdinDir,
|
||||
`${String(stdinSeq + 1).padStart(12, "0")}.json`,
|
||||
);
|
||||
const stdinEndWrite = stdinWriteChain.then(() =>
|
||||
client.writeTextFile(stdinEndPath, jsonLine({ type: "stdinEnd" })),
|
||||
);
|
||||
stdinWriteChain = stdinEndWrite.then(() => undefined, () => undefined);
|
||||
await stdinEndWrite.catch(() => undefined);
|
||||
await client.remove(sessionDir).catch(() => undefined);
|
||||
await fs.rm(proxyDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
},
|
||||
@@ -1706,7 +1964,176 @@ socket.on("close", () => {
|
||||
`;
|
||||
}
|
||||
|
||||
function getProcessSessionRemoteSource(): string {
|
||||
export function getProcessSessionRemoteSource(input?: { outputToStdout?: boolean }): string {
|
||||
return input?.outputToStdout === true
|
||||
? getProcessSessionRemoteStreamSource()
|
||||
: getProcessSessionRemoteEventFileSource();
|
||||
}
|
||||
|
||||
// The shared stdin drain. Both wrappers read newline-delimited stdin messages
|
||||
// from the stdin file queue and write them to the child, then end the child
|
||||
// stdin on `stdinEnd`. A write to a closed child stdin only emits an `error`
|
||||
// event, so the wrapper installs a no-op handler at the call site.
|
||||
const PROCESS_SESSION_STDIN_POLL_TAIL = `child.stdin.on("error", () => {});
|
||||
|
||||
// A stdin file can appear before the host finishes the write. An empty read is
|
||||
// the non-atomic-write window; a partial read makes JSON.parse throw. The
|
||||
// poller must not delete a file before it validates the content. So read and
|
||||
// parse each file first, and delete it only after a successful parse. The files
|
||||
// sort in send order. If an earlier file is not readable yet, stop the cycle and
|
||||
// keep the order: a later file (for example stdinEnd) must not run ahead of it.
|
||||
// Retry the earlier file on a later cycle. After the retry limit, drop the file
|
||||
// and write an error event, so a lost message fails loud, and let later files
|
||||
// run.
|
||||
const stdinMaxParseRetries = (() => {
|
||||
const raw = Number.parseInt(process.env.PAPERCLIP_PROCESS_SESSION_STDIN_MAX_RETRIES || "", 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 100;
|
||||
})();
|
||||
const stdinParseRetries = new Map();
|
||||
// Track the next expected sequence number. The host writes the stdin files in
|
||||
// send order and pads the number to 12 digits, starting at 1. The files sort in
|
||||
// send order. When the smallest present number is greater than expected, an
|
||||
// earlier file has not appeared yet: a missing file, not an unreadable one. Hold
|
||||
// the send order and wait for it, bounded by the same retry budget as the
|
||||
// unreadable-file path. This turns a reordering into a loud error, never silent
|
||||
// corruption.
|
||||
let stdinExpectedSeq = 1;
|
||||
let stdinGapRetries = 0;
|
||||
|
||||
async function pollStdin() {
|
||||
while (!stdinClosed) {
|
||||
const entries = (await fs.readdir(stdinDir).catch(() => [])).filter((name) => name.endsWith(".json")).sort();
|
||||
for (const name of entries) {
|
||||
if (stdinClosed) break;
|
||||
const entrySeq = Number.parseInt(name, 10);
|
||||
// Hold the send order when an earlier file has not appeared. Do not consume
|
||||
// this later file: wait for the missing file on a later cycle, bounded by
|
||||
// the retry budget. After the budget, fail loud and advance past the gap,
|
||||
// so the present file can run.
|
||||
if (Number.isFinite(entrySeq) && entrySeq > stdinExpectedSeq) {
|
||||
stdinGapRetries += 1;
|
||||
if (stdinGapRetries < stdinMaxParseRetries) {
|
||||
break;
|
||||
}
|
||||
await writeEvent({
|
||||
type: "error",
|
||||
message:
|
||||
"Advanced past missing stdin files " + stdinExpectedSeq + " to " + (entrySeq - 1) +
|
||||
" after " + stdinMaxParseRetries + " retries.",
|
||||
});
|
||||
stdinGapRetries = 0;
|
||||
stdinExpectedSeq = entrySeq;
|
||||
}
|
||||
const file = path.posix.join(stdinDir, name);
|
||||
let message;
|
||||
try {
|
||||
const raw = await fs.readFile(file, "utf8");
|
||||
// An empty read means the content is not on disk yet. Treat it the same
|
||||
// as a parse failure: keep the file and retry on a later cycle.
|
||||
if (!raw) throw new Error("stdin file is empty");
|
||||
message = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
const retries = (stdinParseRetries.get(name) || 0) + 1;
|
||||
if (retries >= stdinMaxParseRetries) {
|
||||
// The retry limit is reached. Drop the file and write an error event,
|
||||
// so the lost message fails loud. The file is resolved now, so let the
|
||||
// loop go on to the next entry.
|
||||
stdinParseRetries.delete(name);
|
||||
await fs.rm(file, { force: true }).catch(() => undefined);
|
||||
await writeEvent({
|
||||
type: "error",
|
||||
message:
|
||||
"Dropped unreadable stdin file after " + stdinMaxParseRetries + " retries: " + name + ": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
});
|
||||
// The file is resolved (dropped). Advance the expected number and reset
|
||||
// the gap budget, then let the loop go on to the next entry.
|
||||
if (Number.isFinite(entrySeq)) stdinExpectedSeq = entrySeq + 1;
|
||||
stdinGapRetries = 0;
|
||||
continue;
|
||||
}
|
||||
// The file is not readable yet and is not past the retry limit. Keep it
|
||||
// and stop this cycle to hold the send order. A later file (for example
|
||||
// stdinEnd) must not run before this earlier file. A later cycle reads
|
||||
// from the start again.
|
||||
stdinParseRetries.set(name, retries);
|
||||
break;
|
||||
}
|
||||
// The parse succeeded, so the content is complete. Delete the file first,
|
||||
// then act on the message. A later cycle never re-reads a handled file.
|
||||
stdinParseRetries.delete(name);
|
||||
await fs.rm(file, { force: true }).catch(() => undefined);
|
||||
// The file is handled. Advance the expected number and reset the gap
|
||||
// budget, so the next expected file starts fresh.
|
||||
if (Number.isFinite(entrySeq)) stdinExpectedSeq = entrySeq + 1;
|
||||
stdinGapRetries = 0;
|
||||
if (message.type === "stdin" && typeof message.data === "string") {
|
||||
if (!stdinClosed) child.stdin.write(Buffer.from(message.data, "base64"));
|
||||
} else if (message.type === "stdinEnd") {
|
||||
stdinClosed = true;
|
||||
child.stdin.end();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!stdinClosed) await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
void pollStdin().catch((error) => void writeEvent({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
||||
`;
|
||||
|
||||
// Streamed variant: the wrapper writes each output frame as one newline-
|
||||
// delimited JSON line to its stdout. The host runs this wrapper as one
|
||||
// long-lived session command and reads the frames from the session log stream,
|
||||
// so there is no host output-file poll. Each frame carries a monotonic `seq`,
|
||||
// so the host delivers every frame exactly once whether it arrives live or in
|
||||
// the final result. The wrapper exits when the child closes, so the session
|
||||
// command settles and the session shell (the subshell wrap around it) survives.
|
||||
function getProcessSessionRemoteStreamSource(): string {
|
||||
return `import { spawn } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR;
|
||||
const commandPayload = process.env.PAPERCLIP_PROCESS_SESSION_COMMAND_B64;
|
||||
if (!sessionDir || !commandPayload) throw new Error("Missing process session bridge env.");
|
||||
|
||||
const stdinDir = path.posix.join(sessionDir, "stdin");
|
||||
let seq = 0;
|
||||
let stdinClosed = false;
|
||||
|
||||
const config = JSON.parse(Buffer.from(commandPayload, "base64").toString("utf8"));
|
||||
await fs.mkdir(stdinDir, { recursive: true });
|
||||
|
||||
// One newline-delimited JSON frame per event. Node keeps process.stdout writes
|
||||
// ordered, and the base64 payload holds no newline, so each frame is one line.
|
||||
function writeEvent(event) {
|
||||
seq += 1;
|
||||
process.stdout.write(JSON.stringify({ seq, ...event }) + "\\n");
|
||||
}
|
||||
|
||||
const child = spawn(config.command, Array.isArray(config.args) ? config.args : [], {
|
||||
cwd: config.cwd || process.cwd(),
|
||||
env: { ...process.env, ...(config.env || {}) },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk) => writeEvent({ type: "data", stream: "stdout", data: Buffer.from(chunk).toString("base64") }));
|
||||
child.stderr.on("data", (chunk) => writeEvent({ type: "data", stream: "stderr", data: Buffer.from(chunk).toString("base64") }));
|
||||
child.on("error", (error) => writeEvent({ type: "error", message: error.message }));
|
||||
// "close" (not "exit") so stdout/stderr fully drain before the exit frame.
|
||||
// Stop the stdin poll and set the exit code, then let the event loop drain: a
|
||||
// natural exit flushes the stdout pipe, so the exit frame always lands.
|
||||
child.on("close", (code, signal) => {
|
||||
writeEvent({ type: "exit", code, signal });
|
||||
stdinClosed = true;
|
||||
process.exitCode = typeof code === "number" ? code : 1;
|
||||
});
|
||||
|
||||
${PROCESS_SESSION_STDIN_POLL_TAIL}`;
|
||||
}
|
||||
|
||||
function getProcessSessionRemoteEventFileSource(): string {
|
||||
return `import { spawn } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -1750,29 +2177,7 @@ child.on("error", (error) => void writeEvent({ type: "error", message: error.mes
|
||||
// the write chain then guarantees the exit file lands after every data file.
|
||||
child.on("close", (code, signal) => void writeEvent({ type: "exit", code, signal }));
|
||||
|
||||
async function pollStdin() {
|
||||
while (!stdinClosed) {
|
||||
const entries = (await fs.readdir(stdinDir).catch(() => [])).filter((name) => name.endsWith(".json")).sort();
|
||||
for (const name of entries) {
|
||||
const file = path.posix.join(stdinDir, name);
|
||||
const raw = await fs.readFile(file, "utf8").catch(() => null);
|
||||
await fs.rm(file, { force: true }).catch(() => undefined);
|
||||
if (!raw) continue;
|
||||
const message = JSON.parse(raw);
|
||||
if (message.type === "stdin" && typeof message.data === "string") {
|
||||
child.stdin.write(Buffer.from(message.data, "base64"));
|
||||
} else if (message.type === "stdinEnd") {
|
||||
stdinClosed = true;
|
||||
child.stdin.end();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!stdinClosed) await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
void pollStdin().catch((error) => void writeEvent({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
||||
`;
|
||||
${PROCESS_SESSION_STDIN_POLL_TAIL}`;
|
||||
}
|
||||
|
||||
export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
@@ -1822,11 +2227,19 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
typeof input.maxBodyBytes === "number" && Number.isFinite(input.maxBodyBytes) && input.maxBodyBytes > 0
|
||||
? Math.trunc(input.maxBodyBytes)
|
||||
: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES;
|
||||
const hostApiUrl =
|
||||
input.hostApiUrl?.trim() ||
|
||||
process.env.PAPERCLIP_RUNTIME_API_URL?.trim() ||
|
||||
process.env.PAPERCLIP_API_URL?.trim() ||
|
||||
resolveDefaultPaperclipApiUrl();
|
||||
// The bridge worker runs inside the same process that serves the Paperclip
|
||||
// API, so forwarded sandbox calls must target the LOCAL listen origin. The
|
||||
// PAPERCLIP_RUNTIME_API_URL / PAPERCLIP_API_URL exports now prefer a
|
||||
// configured public base URL, which is the origin browsers and external
|
||||
// agents use; routing this in-process loopback hop through the network edge
|
||||
// breaks deployments whose public origin sits behind a session-gated proxy
|
||||
// (every forwarded agent API call is rejected at the edge). Server boot
|
||||
// exports PAPERCLIP_LISTEN_HOST / PAPERCLIP_LISTEN_PORT before any run
|
||||
// executes, and resolveDefaultPaperclipApiUrl() maps wildcard listen hosts
|
||||
// to the loopback address of the same family (0.0.0.0 -> 127.0.0.1,
|
||||
// :: -> [::1]), so the fallback is always loopback-reachable.
|
||||
// input.hostApiUrl stays available as an explicit override seam.
|
||||
const hostApiUrl = input.hostApiUrl?.trim() || resolveDefaultPaperclipApiUrl();
|
||||
const shellCommand = adapterExecutionTargetShellCommand(target);
|
||||
const runner = adapterExecutionTargetCommandRunner(target);
|
||||
const bridgeTimeoutMs =
|
||||
@@ -1867,7 +2280,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
maxBodyBytes,
|
||||
getRuntimeParentContext: input.getRuntimeParentContext,
|
||||
runtimeSpan: input.runtimeSpan,
|
||||
handleRequest: async (request) => {
|
||||
handleRequest: async (request, options) => {
|
||||
const method = request.method.trim().toUpperCase() || "GET";
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
@@ -1882,11 +2295,19 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
}
|
||||
headers.set("authorization", `Bearer ${hostApiToken}`);
|
||||
headers.set("x-paperclip-run-id", input.runId);
|
||||
// Abort the forward when the worker aborts the request (its per-iteration
|
||||
// timeout or watchdog fired), or after the 30s ceiling, whichever comes
|
||||
// first. The worker abort lets the bridge fail a hung forward fast
|
||||
// instead of stranding the request until the 30s ceiling.
|
||||
const timeoutSignal = AbortSignal.timeout(30_000);
|
||||
const forwardSignal = options?.signal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
|
||||
method,
|
||||
headers,
|
||||
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
signal: forwardSignal,
|
||||
});
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
@@ -1894,10 +2315,13 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
||||
);
|
||||
}
|
||||
const responseBody = await readBridgeForwardResponseBody(response, maxBodyBytes);
|
||||
const commentMarker = postedIssueCommentLogMarker(method, request.path, response.status, responseBody);
|
||||
if (commentMarker) await onLog("stdout", commentMarker);
|
||||
return {
|
||||
status: response.status,
|
||||
headers: buildBridgeResponseHeaders(response),
|
||||
body: await readBridgeForwardResponseBody(response, maxBodyBytes),
|
||||
body: responseBody,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
readGitWorkspaceSnapshot,
|
||||
runLocalGit,
|
||||
sanitizeGitRemoteUrl,
|
||||
setExpensiveWorkspaceGitExecutor,
|
||||
withShallowGitWorkspaceClone,
|
||||
} from "./git-workspace-sync.js";
|
||||
|
||||
@@ -28,6 +29,7 @@ describe("git workspace sync", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
setExpensiveWorkspaceGitExecutor(null);
|
||||
while (cleanupDirs.length > 0) {
|
||||
const dir = cleanupDirs.pop();
|
||||
if (!dir) continue;
|
||||
@@ -35,6 +37,31 @@ describe("git workspace sync", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("delegates every host-side full-tree enumeration to the registered scheduler", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-scheduler-hook-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const repo = await createRepo(rootDir);
|
||||
await writeFile(path.join(repo, "untracked.txt"), "untracked\n", "utf8");
|
||||
const operations: string[] = [];
|
||||
setExpensiveWorkspaceGitExecutor(async (input) => {
|
||||
operations.push(input.operation);
|
||||
return await runLocalGit(input.localDir, [...input.args], {
|
||||
timeout: input.timeout,
|
||||
maxBuffer: input.maxBuffer,
|
||||
});
|
||||
});
|
||||
|
||||
const snapshot = await readGitWorkspaceSnapshot(repo);
|
||||
|
||||
expect(snapshot?.overlayPaths).toContain("untracked.txt");
|
||||
expect(operations.sort()).toEqual([
|
||||
"adapter_sync.deleted_files",
|
||||
"adapter_sync.ignored_files",
|
||||
"adapter_sync.overlay_diff",
|
||||
"adapter_sync.untracked_files",
|
||||
]);
|
||||
});
|
||||
|
||||
async function createRepo(rootDir: string): Promise<string> {
|
||||
const repo = path.join(rootDir, "repo");
|
||||
await mkdir(repo, { recursive: true });
|
||||
|
||||
@@ -17,6 +17,29 @@ export interface GitWorkspaceSnapshot {
|
||||
ignoredPaths: string[];
|
||||
}
|
||||
|
||||
export interface ExpensiveWorkspaceGitInput {
|
||||
localDir: string;
|
||||
args: readonly string[];
|
||||
operation: string;
|
||||
timeout: number;
|
||||
maxBuffer: number;
|
||||
}
|
||||
|
||||
export type ExpensiveWorkspaceGitExecutor = (
|
||||
input: ExpensiveWorkspaceGitInput,
|
||||
) => Promise<GitCommandResult>;
|
||||
|
||||
let expensiveWorkspaceGitExecutor: ExpensiveWorkspaceGitExecutor | null = null;
|
||||
|
||||
/**
|
||||
* Lets a host process apply its process-wide admission policy to the adapter
|
||||
* package's full-tree Git walks. Standalone adapter-utils consumers retain the
|
||||
* existing timeout/buffer-bounded fallback.
|
||||
*/
|
||||
export function setExpensiveWorkspaceGitExecutor(executor: ExpensiveWorkspaceGitExecutor | null): void {
|
||||
expensiveWorkspaceGitExecutor = executor;
|
||||
}
|
||||
|
||||
export const GIT_ARCHIVE_EXCLUDES = [".git", ".git/*"] as const;
|
||||
|
||||
function shellQuote(value: string) {
|
||||
@@ -53,6 +76,24 @@ export async function runLocalGit(
|
||||
});
|
||||
}
|
||||
|
||||
async function runExpensiveWorkspaceGit(
|
||||
localDir: string,
|
||||
args: string[],
|
||||
operation: string,
|
||||
options: { timeout: number; maxBuffer: number },
|
||||
): Promise<GitCommandResult> {
|
||||
if (expensiveWorkspaceGitExecutor) {
|
||||
return await expensiveWorkspaceGitExecutor({
|
||||
localDir,
|
||||
args,
|
||||
operation,
|
||||
timeout: options.timeout,
|
||||
maxBuffer: options.maxBuffer,
|
||||
});
|
||||
}
|
||||
return await runLocalGit(localDir, args, options);
|
||||
}
|
||||
|
||||
export async function readGitWorkspaceSnapshot(localDir: string): Promise<GitWorkspaceSnapshot | null> {
|
||||
try {
|
||||
const insideWorkTree = await runLocalGit(localDir, ["rev-parse", "--is-inside-work-tree"], {
|
||||
@@ -72,19 +113,19 @@ export async function readGitWorkspaceSnapshot(localDir: string): Promise<GitWor
|
||||
timeout: 10_000,
|
||||
maxBuffer: 16 * 1024,
|
||||
}),
|
||||
runLocalGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=ACMRTUXB", "HEAD", "--"], {
|
||||
runExpensiveWorkspaceGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=ACMRTUXB", "HEAD", "--"], "adapter_sync.overlay_diff", {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}),
|
||||
runLocalGit(localDir, ["ls-files", "--others", "--exclude-standard", "-z"], {
|
||||
runExpensiveWorkspaceGit(localDir, ["ls-files", "--others", "--exclude-standard", "-z"], "adapter_sync.untracked_files", {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}),
|
||||
runLocalGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=D", "HEAD", "--"], {
|
||||
runExpensiveWorkspaceGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=D", "HEAD", "--"], "adapter_sync.deleted_files", {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 256 * 1024,
|
||||
}),
|
||||
runLocalGit(localDir, ["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"], {
|
||||
runExpensiveWorkspaceGit(localDir, ["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"], "adapter_sync.ignored_files", {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}),
|
||||
|
||||
Loaded 100 of 995 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user