@edgehero/pi-dispatch 4.3.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Explicit subscription bindings. A configured connection owns its provider, including on failure. */
|
|
2
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
3
|
+
import { authError, createAuthStore, validateAuthCredential } from "./auth-store.mjs";
|
|
4
|
+
import { createDefaultAuthAdapters } from "./community-auth-adapters.mjs";
|
|
5
|
+
|
|
6
|
+
import { parseAuthConnections } from "./auth-policy.mjs";
|
|
7
|
+
export { parseAuthConnections } from "./auth-policy.mjs";
|
|
8
|
+
|
|
9
|
+
export const AUTH_CONNECTIONS_VERSION = 1;
|
|
10
|
+
export const AUTH_SNAPSHOT_MARGIN_MS = 5 * 60_000 + 60_000;
|
|
11
|
+
export const authBindingSignature = (connection) => createHash("sha256").update(JSON.stringify(connection)).digest("hex");
|
|
12
|
+
function authFailure(error, fallback) {
|
|
13
|
+
const code = typeof error?.code === "string" && /^auth-[a-z-]+$/.test(error.code) ? error.code : null;
|
|
14
|
+
return { ok: false, managed: true, reason: code ?? fallback, unavailable: code === null || error.transient === true };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createAuthService({ policy = null, readPolicy = () => policy, storeDir, host, adapters = null, now = () => Date.now(), fetch = globalThis.fetch, fs, store = createAuthStore({ dir: storeDir, ...(fs ? { fs } : {}) }), marginMs = AUTH_SNAPSHOT_MARGIN_MS } = {}) {
|
|
18
|
+
adapters ??= createDefaultAuthAdapters({ now, fetch });
|
|
19
|
+
const read = async () => parseAuthConnections(await readPolicy());
|
|
20
|
+
const bindingStillCurrent = async (row) => { const current = await read(); return current.bindings[row.provider] === row.id && authBindingSignature(current.connections.find((entry) => entry.id === row.id)) === authBindingSignature(row); };
|
|
21
|
+
const connection = async (id) => { const row = (await read()).connections.find((entry) => entry.id === id); if (!row) throw authError("auth-connection-missing"); return row; };
|
|
22
|
+
const initial = (row) => ({ version: 1, id: row.id, provider: row.provider, adapter: row.adapter, generation: 0, accountGroup: randomUUID(), state: "disconnected", credential: null, registration: { hostId: randomUUID() }, lastRefreshAt: null });
|
|
23
|
+
function matching(row, saved) { return saved && saved.provider === row.provider && saved.adapter === row.adapter; }
|
|
24
|
+
async function validate(row) {
|
|
25
|
+
if (row.ownerHost !== host) throw authError("auth-owner-host");
|
|
26
|
+
const adapter = adapters[row.adapter];
|
|
27
|
+
if (!adapter) throw authError("auth-adapter-unavailable");
|
|
28
|
+
await adapter.validate?.(row);
|
|
29
|
+
return adapter;
|
|
30
|
+
}
|
|
31
|
+
function safe(row, saved, active, state = saved?.state ?? "disconnected", compatibility = state === "adapter-unavailable" ? "unavailable" : state === "other-host" ? "unknown" : "verified") {
|
|
32
|
+
return { id: row.id, provider: row.provider, adapter: row.adapter, ownerHost: row.ownerHost, active, state, compatibility, expiresAt: saved?.credential?.expires ?? null, generation: saved?.generation ?? 0, accountGroup: saved?.accountGroup ?? null, lastRefreshAt: saved?.lastRefreshAt ?? null, extraUsage: saved?.extraUsage ?? "unknown", extraUsageCreditMicros: row.extraUsageCreditMicros ?? null, quotaWindows: [...row.quotaWindows] };
|
|
33
|
+
}
|
|
34
|
+
async function status() {
|
|
35
|
+
const config = await read();
|
|
36
|
+
return Promise.all(config.connections.map(async (row) => {
|
|
37
|
+
if (row.ownerHost !== host) return safe(row, null, config.bindings[row.provider] === row.id, "other-host");
|
|
38
|
+
let saved;
|
|
39
|
+
try { saved = store.read(row.id); if (saved && !matching(row, saved)) return safe(row, null, config.bindings[row.provider] === row.id, "adapter-unavailable"); await validate(row); }
|
|
40
|
+
catch (error) { const compatibility = ["ENOENT", "auth-adapter-unstaged"].includes(error.code) ? "unstaged" : error.code === "auth-adapter-version-mismatch" ? "version-mismatch" : ["auth-adapter-source-mismatch", "auth-adapter-unpinned"].includes(error.code) ? "unpinned" : "unavailable"; return safe(row, null, config.bindings[row.provider] === row.id, "adapter-unavailable", compatibility); }
|
|
41
|
+
return safe(row, saved, config.bindings[row.provider] === row.id);
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
async function check(provider, requested = {}) {
|
|
45
|
+
let config;
|
|
46
|
+
try { config = await read(); } catch (error) { return authFailure(error, "auth-policy-unavailable"); }
|
|
47
|
+
const id = config.bindings[provider];
|
|
48
|
+
if (!id) return { ok: true, managed: false };
|
|
49
|
+
const row = config.connections.find((entry) => entry.id === id);
|
|
50
|
+
try {
|
|
51
|
+
await validate(row);
|
|
52
|
+
if (requested.api && requested.api !== (provider === "openai" ? "openai-responses" : "anthropic-messages")) throw authError("auth-capability-unsupported");
|
|
53
|
+
if (requested.baseUrl && requested.baseUrl.replace(/\/$/, "") !== (provider === "openai" ? "https://api.openai.com/v1" : "https://api.anthropic.com")) throw authError("auth-endpoint-unsupported");
|
|
54
|
+
const judge = async (saved) => {
|
|
55
|
+
if (!matching(row, saved) || !saved.credential || saved.state !== "connected") throw authError(saved?.state === "refresh-uncertain" ? "auth-refresh-uncertain" : "auth-reauth-required");
|
|
56
|
+
const publicRow = safe(row, saved, true);
|
|
57
|
+
return { ok: true, managed: true, ...publicRow, connectionId: id, connection: publicRow, bindingSignature: authBindingSignature(row), extraUsageCreditMicros: row.extraUsageCreditMicros ?? null };
|
|
58
|
+
};
|
|
59
|
+
const saved = store.read(id);
|
|
60
|
+
// Ordinary metadata reads are atomic and write nothing. Workers wait out an in-flight renewal;
|
|
61
|
+
// doctor explicitly asks for readOnly and reports its observed marker without acquiring a lock.
|
|
62
|
+
return await (saved?.state === "refresh-uncertain" && requested.readOnly !== true ? store.transaction(id, judge) : judge(saved));
|
|
63
|
+
} catch (error) { return authFailure(error, "auth-store-unavailable"); }
|
|
64
|
+
}
|
|
65
|
+
async function login(id, { interaction, signal } = {}) {
|
|
66
|
+
const row = await connection(id);
|
|
67
|
+
const adapter = await validate(row);
|
|
68
|
+
return store.transaction(id, async (saved, write) => {
|
|
69
|
+
const current = matching(row, saved) ? saved : initial(row);
|
|
70
|
+
const disabled = { ...current, generation: current.generation + 1, state: "disconnected", credential: null };
|
|
71
|
+
await write(disabled);
|
|
72
|
+
const result = await adapter.login({ interaction, signal, connection: row, previous: current });
|
|
73
|
+
const credential = validateAuthCredential(result.credential ?? result, { provider: row.provider });
|
|
74
|
+
if (credential.expires <= now()) throw authError("auth-credential-expired");
|
|
75
|
+
const next = { ...disabled, state: "connected", credential, registration: result.registration ?? current.registration, lastRefreshAt: now() };
|
|
76
|
+
await write(next);
|
|
77
|
+
return safe(row, next, (await read()).bindings[row.provider] === id);
|
|
78
|
+
}, { signal });
|
|
79
|
+
}
|
|
80
|
+
async function prepareSnapshotWithPolicy(provider, { deadlineAt, signal } = {}) {
|
|
81
|
+
if (!Number.isSafeInteger(deadlineAt) || deadlineAt <= now()) return { ok: false, reason: "auth-deadline-invalid" };
|
|
82
|
+
const checked = await check(provider);
|
|
83
|
+
if (!checked.ok || !checked.managed) return { ...checked, ...(!checked.managed ? { snapshot: null } : {}) };
|
|
84
|
+
const row = await connection(checked.connectionId);
|
|
85
|
+
const adapter = await validate(row);
|
|
86
|
+
return store.transaction(row.id, async (saved, write) => {
|
|
87
|
+
if (!(await bindingStillCurrent(row))) return { ok: false, reason: "auth-binding-changed" };
|
|
88
|
+
if (!matching(row, saved) || saved.state !== "connected" || !saved.credential) return { ok: false, reason: saved?.state === "refresh-uncertain" ? "auth-refresh-uncertain" : "auth-reauth-required" };
|
|
89
|
+
const neededUntil = deadlineAt + marginMs;
|
|
90
|
+
let current = saved;
|
|
91
|
+
if (current.credential.expires <= neededUntil) {
|
|
92
|
+
if (Number.isSafeInteger(current.credential.earliestRefreshAt) && current.credential.earliestRefreshAt > now()) return { ok: false, reason: "auth-refresh-too-early", wait: "subscription-auth", until: current.credential.earliestRefreshAt, unavailable: false };
|
|
93
|
+
await write({ ...current, state: "refresh-uncertain" });
|
|
94
|
+
try {
|
|
95
|
+
// Once renewal begins, caller cancellation cannot discard a rotated replacement.
|
|
96
|
+
const credential = validateAuthCredential(await adapter.refresh({ credential: current.credential, connection: row, previous: current, signal: AbortSignal.timeout(15_000) }), { provider: row.provider });
|
|
97
|
+
current = { ...current, credential, state: "connected", generation: current.generation + 1, lastRefreshAt: now() };
|
|
98
|
+
await write(current);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// Only the adapter's read-only preflight can prove the old grant never reached a provider.
|
|
101
|
+
if (error.beforeRefresh === true) await write(current);
|
|
102
|
+
else if (error.code?.startsWith("auth-") && error.uncertain !== true) await write({ ...current, state: error.transient ? "connected" : "reauth-required", ...(error.transient ? {} : { credential: null }) });
|
|
103
|
+
return { ok: false, reason: error.code?.startsWith("auth-") ? error.code : "auth-refresh-uncertain", unavailable: error.transient === true };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
if (!(await bindingStillCurrent(row))) return { ok: false, reason: "auth-binding-changed" };
|
|
108
|
+
if (current.credential.expires <= neededUntil || deadlineAt <= now()) return { ok: false, reason: "auth-snapshot-too-short" };
|
|
109
|
+
const variable = provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_OAUTH_TOKEN";
|
|
110
|
+
return { ok: true, managed: true, snapshot: { kind: "subscription", provider, connectionId: row.id, adapter: row.adapter, accountGroup: current.accountGroup, generation: current.generation, expiresAt: current.credential.expires, deadlineAt, bindingSignature: authBindingSignature(row), env: { [variable]: current.credential.access } } };
|
|
111
|
+
}, { signal });
|
|
112
|
+
}
|
|
113
|
+
async function prepareSnapshot(provider, options = {}) {
|
|
114
|
+
try { return await prepareSnapshotWithPolicy(provider, options); }
|
|
115
|
+
catch (error) { if (options.signal?.aborted) throw error; return authFailure(error, "auth-state-unavailable"); }
|
|
116
|
+
}
|
|
117
|
+
async function validateSnapshot(snapshot) {
|
|
118
|
+
const checked = await check(snapshot?.provider);
|
|
119
|
+
if (!(checked.ok === true && checked.managed === true && snapshot.kind === "subscription" && checked.connectionId === snapshot.connectionId && checked.bindingSignature === snapshot.bindingSignature && checked.generation === snapshot.generation && checked.accountGroup === snapshot.accountGroup && snapshot.expiresAt > snapshot.deadlineAt + marginMs && snapshot.deadlineAt > now())) return false;
|
|
120
|
+
return store.transaction(snapshot.connectionId, async (saved) => {
|
|
121
|
+
const variable = snapshot.provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_OAUTH_TOKEN";
|
|
122
|
+
return saved?.state === "connected" && saved.generation === snapshot.generation && saved.adapter === snapshot.adapter && saved.credential?.expires === snapshot.expiresAt && snapshot.env && Object.keys(snapshot.env).length === 1 && snapshot.env[variable] === saved.credential.access;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async function logout(id, { signal } = {}) {
|
|
126
|
+
const row = await connection(id);
|
|
127
|
+
if (row.ownerHost !== host) throw authError("auth-owner-host");
|
|
128
|
+
return store.transaction(id, async (saved, write) => {
|
|
129
|
+
const current = matching(row, saved) ? saved : initial(row);
|
|
130
|
+
await write({ ...current, generation: current.generation + 1, state: "disconnected", credential: null });
|
|
131
|
+
let confirmed = false;
|
|
132
|
+
try { const adapter = await validate(row); confirmed = current.credential ? (await adapter.revoke?.({ credential: current.credential, signal: signal ?? AbortSignal.timeout(15_000) }))?.confirmed === true : !saved?.credential; } catch {}
|
|
133
|
+
return { id, state: "disconnected", remoteRevocationConfirmed: confirmed };
|
|
134
|
+
}, { signal });
|
|
135
|
+
}
|
|
136
|
+
async function usage(id, { signal } = {}) {
|
|
137
|
+
const row = await connection(id);
|
|
138
|
+
const prepared = await prepareSnapshot(row.provider, { deadlineAt: now() + 1_000, signal });
|
|
139
|
+
if (!prepared.ok || prepared.snapshot?.connectionId !== id) return { kind: "unavailable", reason: prepared.reason ?? "auth-connection-inactive" };
|
|
140
|
+
const adapter = await validate(row);
|
|
141
|
+
if (!adapter.usage) return { kind: "unavailable", reason: "auth-usage-unsupported" };
|
|
142
|
+
return { kind: "observation", observedAt: now(), connectionId: id, provider: row.provider, ownerHost: row.ownerHost, accountGroup: prepared.snapshot.accountGroup, generation: prepared.snapshot.generation, payload: await adapter.usage({ access: Object.values(prepared.snapshot.env)[0], signal }) };
|
|
143
|
+
}
|
|
144
|
+
return { status, check, login, logout, prepareSnapshot, validateSnapshot, usage };
|
|
145
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Exact subscription-policy parser shared with read-only metadata consumers. */
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { SUBSCRIPTION_CONNECTION_LIMIT } from "./subscription-limits.mjs";
|
|
4
|
+
import { AUTH_ID_RE, authError } from "./auth-store.mjs";
|
|
5
|
+
import { COMMUNITY_AUTH_ADAPTERS } from "./community-auth-metadata.mjs";
|
|
6
|
+
import { MAX_USD_MICROS } from "./money.mjs";
|
|
7
|
+
const PROVIDERS = new Set(["openai", "anthropic"]);
|
|
8
|
+
const onlyKeys = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
|
|
9
|
+
export function parseAuthConnections(text) {
|
|
10
|
+
let value;
|
|
11
|
+
try { value = typeof text === "string" ? JSON.parse(text) : text; } catch { throw authError("auth-policy-invalid"); }
|
|
12
|
+
if (!value || value.version !== 1 || !onlyKeys(value, ["version", "connections", "bindings"]) || !Array.isArray(value.connections) || value.connections.length > SUBSCRIPTION_CONNECTION_LIMIT || !value.bindings || typeof value.bindings !== "object" || Array.isArray(value.bindings)) throw authError("auth-policy-invalid");
|
|
13
|
+
const ids = new Set();
|
|
14
|
+
const connections = value.connections.map((row) => {
|
|
15
|
+
if (!row || !onlyKeys(row, ["id", "provider", "adapter", "ownerHost", "root", "digest", "extraUsageCreditMicros", "quotaWindows"]) || typeof row.id !== "string" || !AUTH_ID_RE.test(row.id) || ids.has(row.id) || !PROVIDERS.has(row.provider) || typeof row.ownerHost !== "string" || !AUTH_ID_RE.test(row.ownerHost)) throw authError("auth-policy-invalid");
|
|
16
|
+
ids.add(row.id);
|
|
17
|
+
const adapter = COMMUNITY_AUTH_ADAPTERS[row.adapter];
|
|
18
|
+
if (!adapter || adapter.provider !== row.provider) throw authError("auth-adapter-unsupported");
|
|
19
|
+
if (adapter.community && (typeof row.root !== "string" || !isAbsolute(row.root) || typeof row.digest !== "string" || !/^[a-f0-9]{64}$/.test(row.digest))) throw authError("auth-adapter-unapproved");
|
|
20
|
+
if (!adapter.community && (row.root !== undefined || row.digest !== undefined)) throw authError("auth-policy-invalid");
|
|
21
|
+
if (row.extraUsageCreditMicros !== undefined && (!Number.isSafeInteger(row.extraUsageCreditMicros) || row.extraUsageCreditMicros <= 0 || row.extraUsageCreditMicros > MAX_USD_MICROS)) throw authError("auth-policy-invalid");
|
|
22
|
+
const quotaWindows = row.quotaWindows ?? ["five-hour", "seven-day"];
|
|
23
|
+
if (!Array.isArray(quotaWindows) || quotaWindows.length > 2 || new Set(quotaWindows).size !== quotaWindows.length || quotaWindows.some((item) => !["five-hour", "seven-day"].includes(item))) throw authError("auth-policy-invalid");
|
|
24
|
+
return { id: row.id, provider: row.provider, adapter: row.adapter, ownerHost: row.ownerHost, quotaWindows: [...quotaWindows], ...(row.extraUsageCreditMicros === undefined ? {} : { extraUsageCreditMicros: row.extraUsageCreditMicros }), ...(adapter.community ? { root: row.root, digest: row.digest } : {}) };
|
|
25
|
+
});
|
|
26
|
+
const bindings = {};
|
|
27
|
+
for (const [provider, id] of Object.entries(value.bindings)) {
|
|
28
|
+
if (!PROVIDERS.has(provider) || !connections.some((row) => row.id === id && row.provider === provider)) throw authError("auth-policy-invalid");
|
|
29
|
+
bindings[provider] = id;
|
|
30
|
+
}
|
|
31
|
+
return { version: 1, connections, bindings };
|
|
32
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Host-only credential transactions. A refresh marker survives an ambiguous remote rotation. */
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { isDeterminateFsCode } from "./transient.mjs";
|
|
6
|
+
|
|
7
|
+
export function authError(code, { transient = false, uncertain = false } = {}) {
|
|
8
|
+
const error = new Error(code);
|
|
9
|
+
error.code = code;
|
|
10
|
+
error.transient = transient;
|
|
11
|
+
error.uncertain = uncertain;
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function storeReadError(error) {
|
|
16
|
+
if (error?.code?.startsWith("auth-")) return error;
|
|
17
|
+
const invalid = error instanceof SyntaxError || isDeterminateFsCode(error?.code);
|
|
18
|
+
return authError(invalid ? "auth-store-invalid" : "auth-store-unavailable", { transient: !invalid });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const AUTH_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
22
|
+
// RFC 6750 bearer grammar permits opaque tokens; an API key must never enter pi's ChatGPT branch.
|
|
23
|
+
export const validOpenAIAccessToken = (value) => typeof value === "string" && value.length > 0 && value.length <= 65_536 && !/^sk-/i.test(value) && /^[A-Za-z0-9._~+/-]+=*$/.test(value);
|
|
24
|
+
export function validateAuthCredential(value, { provider } = {}) {
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw authError("auth-credential-invalid");
|
|
26
|
+
for (const name of ["access", "refresh"]) if (typeof value[name] !== "string" || !value[name] || value[name].length > 65_536 || /[\r\n\0]/.test(value[name])) throw authError("auth-credential-invalid");
|
|
27
|
+
if (provider === "openai" && !validOpenAIAccessToken(value.access)) throw authError("auth-credential-invalid");
|
|
28
|
+
if (provider === "anthropic" && (!value.access.startsWith("sk-ant-oat") || !/^[A-Za-z0-9._~+/-]+=*$/.test(value.access))) throw authError("auth-credential-invalid");
|
|
29
|
+
if (!Number.isSafeInteger(value.expires) || value.expires <= 0 || value.expires > 253_402_300_799_999) throw authError("auth-credential-invalid");
|
|
30
|
+
return { ...value, type: "oauth" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createAuthStore({ dir, fs = nodeFs, lock = null, uid = process.getuid?.() } = {}) {
|
|
34
|
+
if (typeof dir !== "string" || !dir.startsWith("/")) throw authError("auth-store-invalid");
|
|
35
|
+
function secure(path, directory = false) {
|
|
36
|
+
const stat = fs.lstatSync(path);
|
|
37
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (stat.mode & 0o077) !== 0 || (uid !== undefined && stat.uid !== uid)) throw authError("auth-store-permissions");
|
|
38
|
+
}
|
|
39
|
+
function initialize() {
|
|
40
|
+
try {
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
secure(dir, true);
|
|
43
|
+
} catch (error) { throw storeReadError(error); }
|
|
44
|
+
}
|
|
45
|
+
function pathFor(id) {
|
|
46
|
+
if (typeof id !== "string" || !AUTH_ID_RE.test(id)) throw authError("auth-connection-invalid");
|
|
47
|
+
return join(dir, `${id}.json`);
|
|
48
|
+
}
|
|
49
|
+
function read(id) {
|
|
50
|
+
try {
|
|
51
|
+
try { secure(dir, true); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
52
|
+
const path = pathFor(id);
|
|
53
|
+
try { secure(path); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
54
|
+
const fd = fs.openSync(path, nodeFs.constants.O_RDONLY | nodeFs.constants.O_NOFOLLOW);
|
|
55
|
+
let text;
|
|
56
|
+
try { if (fs.fstatSync(fd).size > 512 * 1024) throw authError("auth-store-invalid"); text = fs.readFileSync(fd, "utf8"); } finally { fs.closeSync(fd); }
|
|
57
|
+
const row = JSON.parse(text);
|
|
58
|
+
if (row?.version !== 1 || row.id !== id || !Number.isSafeInteger(row.generation) || row.generation < 0 || !["connected", "disconnected", "reauth-required", "refresh-uncertain"].includes(row.state) || typeof row.accountGroup !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(row.accountGroup) || (row.lastRefreshAt !== null && !Number.isSafeInteger(row.lastRefreshAt)) || (row.extraUsage !== undefined && !["unknown", "enabled", "disabled"].includes(row.extraUsage))) throw authError("auth-store-invalid");
|
|
59
|
+
if (row.credential !== null) validateAuthCredential(row.credential, { provider: row.provider });
|
|
60
|
+
return row;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Only malformed bytes or a determinate path error can certify a policy refusal.
|
|
63
|
+
throw storeReadError(error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function write(id, row) {
|
|
67
|
+
const path = pathFor(id);
|
|
68
|
+
const temporary = join(dir, `.${id}.${randomUUID()}.tmp`);
|
|
69
|
+
let fd;
|
|
70
|
+
try {
|
|
71
|
+
fd = fs.openSync(temporary, "wx", 0o600);
|
|
72
|
+
fs.writeFileSync(fd, JSON.stringify({ ...row, version: 1, id }));
|
|
73
|
+
fs.fsyncSync(fd);
|
|
74
|
+
fs.closeSync(fd); fd = undefined;
|
|
75
|
+
fs.renameSync(temporary, path);
|
|
76
|
+
const directory = fs.openSync(dir, "r");
|
|
77
|
+
try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
|
|
78
|
+
} finally {
|
|
79
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
80
|
+
try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function transaction(id, fn, { signal } = {}) {
|
|
84
|
+
initialize();
|
|
85
|
+
const path = pathFor(id);
|
|
86
|
+
// proper-lockfile locks a name, not the inode replaced by atomic rename. Never steal a live holder.
|
|
87
|
+
let compromised = false;
|
|
88
|
+
let release;
|
|
89
|
+
try { const acquire = lock ?? (await import("proper-lockfile")).default.lock; release = await acquire(path, { realpath: false, stale: 30_000, update: 5_000, retries: { retries: 80, minTimeout: 25, maxTimeout: 250 }, onCompromised: () => { compromised = true; } }); }
|
|
90
|
+
catch { throw authError("auth-store-unavailable", { transient: true }); }
|
|
91
|
+
try {
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
|
+
return await fn(read(id), async (row) => { if (compromised) throw authError("auth-store-lock-lost"); write(id, row); });
|
|
94
|
+
} finally { await release(); }
|
|
95
|
+
}
|
|
96
|
+
return { read, transaction };
|
|
97
|
+
}
|
package/src/azure-host.mjs
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { configError } from "./config.mjs";
|
|
25
25
|
import { PARKED_LABEL } from "./parked.mjs";
|
|
26
26
|
import { InfraRetry } from "./processor.mjs";
|
|
27
|
+
import { commentIdOf, isStatusCommentId } from "./status-comment.mjs";
|
|
27
28
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
28
29
|
import { isDeterminateFetchFailure } from "./transient.mjs";
|
|
29
30
|
|
|
@@ -182,6 +183,67 @@ export function makeAzureHost({ orgUrl, fetchFn = fetch } = {}) {
|
|
|
182
183
|
if (!res.ok) {
|
|
183
184
|
throw new InfraRetry(`azure-host: POST ${path} returned ${res.status}`);
|
|
184
185
|
}
|
|
186
|
+
// The created comment's id, for the status commenter (issue #644), in the form `editStatusComment` routes on:
|
|
187
|
+
// `<threadId>:<commentId>` for a pull request thread (the create answers the thread, its comments inside) and
|
|
188
|
+
// `wi:<commentId>` for a work item comment. Read after the 2xx and never thrown on: the comment exists, and a
|
|
189
|
+
// retry would post a second one.
|
|
190
|
+
const created = await res.json().catch(() => null);
|
|
191
|
+
if (isPr) {
|
|
192
|
+
const threadId = commentIdOf(created?.id);
|
|
193
|
+
const commentId = commentIdOf(Array.isArray(created?.comments) ? created.comments[0]?.id : null);
|
|
194
|
+
return { id: threadId && commentId ? `${threadId}:${commentId}` : null };
|
|
195
|
+
}
|
|
196
|
+
const commentId = commentIdOf(created?.id);
|
|
197
|
+
return { id: commentId ? `wi:${commentId}` : null };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Replace the text of the status comment `id` this host posted (issue #644, status-comment.mjs). The id says which
|
|
202
|
+
* API it lives in, in the form `postStatusComment` returns:
|
|
203
|
+
* - `<threadId>:<commentId>`: a pull request thread comment, `PATCH .../pullRequests/{pr}/threads/{t}/comments/{c}`
|
|
204
|
+
* with `{ content }`;
|
|
205
|
+
* - `wi:<commentId>`: a work item comment, `PATCH .../wit/workItems/{w}/comments/{c}` with `{ text }`, on the same
|
|
206
|
+
* pinned preview api-version the post uses.
|
|
207
|
+
* An id whose form disagrees with `target.type` is refused rather than sent: the pull request or work item number
|
|
208
|
+
* comes from the target, and the wrong pairing would address a different object. The id is checked against
|
|
209
|
+
* `STATUS_COMMENT_ID_RE` before it reaches a URL. An HTTP failure throws InfraRetry with `status` set, so the
|
|
210
|
+
* caller can tell a 403/404/429 (stop editing) from a blip.
|
|
211
|
+
*/
|
|
212
|
+
async function editStatusComment(ref, target, id, text, token) {
|
|
213
|
+
const { project } = scopeOf(ref);
|
|
214
|
+
const isPr = target?.type === "pull_request";
|
|
215
|
+
const thread = isStatusCommentId(id) ? /^([0-9]+):([0-9]+)$/.exec(id) : null;
|
|
216
|
+
const workItem = isStatusCommentId(id) ? /^wi:([0-9]+)$/.exec(id) : null;
|
|
217
|
+
if (isPr ? !thread : !workItem) {
|
|
218
|
+
throw configError(`azure-host: malformed status comment id for a ${isPr ? "pull request" : "work item"}: ${JSON.stringify(id)}`);
|
|
219
|
+
}
|
|
220
|
+
let path;
|
|
221
|
+
let body;
|
|
222
|
+
if (isPr) {
|
|
223
|
+
const repo = await repoOf(ref, token);
|
|
224
|
+
path = `/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repo?.id)}/pullRequests/${encodeURIComponent(target?.number)}/threads/${thread[1]}/comments/${thread[2]}?api-version=${API_VERSION}`;
|
|
225
|
+
body = { content: text };
|
|
226
|
+
} else {
|
|
227
|
+
path = `/${encodeURIComponent(project)}/_apis/wit/workItems/${encodeURIComponent(target?.number)}/comments/${workItem[1]}?api-version=7.1-preview.4`;
|
|
228
|
+
body = { text };
|
|
229
|
+
}
|
|
230
|
+
let res;
|
|
231
|
+
try {
|
|
232
|
+
res = await fetchFn(`${root}${path}`, {
|
|
233
|
+
method: "PATCH",
|
|
234
|
+
headers: { Authorization: authHeader(token), "content-type": "application/json" },
|
|
235
|
+
body: JSON.stringify(body),
|
|
236
|
+
redirect: "error",
|
|
237
|
+
});
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (isDeterminateFetchFailure(err)) {
|
|
240
|
+
throw configError(`azure-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
|
|
241
|
+
}
|
|
242
|
+
throw new InfraRetry(`azure-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
|
|
243
|
+
}
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
throw Object.assign(new InfraRetry(`azure-host: PATCH ${path} returned ${res.status}`), { status: res.status });
|
|
246
|
+
}
|
|
185
247
|
}
|
|
186
248
|
|
|
187
249
|
/**
|
|
@@ -208,12 +270,12 @@ export function makeAzureHost({ orgUrl, fetchFn = fetch } = {}) {
|
|
|
208
270
|
* whole, so this reads it, edits the one tag and writes the result; Azure creates a tag the project lacks. The caller treats any throw as fail-open:
|
|
209
271
|
* the session store's sidecar, not this label, decides whether a reply resumes anything.
|
|
210
272
|
*/
|
|
211
|
-
async function setParkedLabel(ref, target, on, token) {
|
|
273
|
+
async function setParkedLabel(ref, target, on, token, label = PARKED_LABEL) {
|
|
212
274
|
const { project } = scopeOf(ref);
|
|
213
275
|
const path = `/${encodeURIComponent(project)}/_apis/wit/workitems/${encodeURIComponent(target?.number)}`;
|
|
214
276
|
const item = await get(`${path}?fields=System.Tags&api-version=${API_VERSION}`, token);
|
|
215
|
-
const tags = String(item?.fields?.["System.Tags"] ?? "").split(";").map((tag) => tag.trim()).filter((tag) => tag !== "" && tag !==
|
|
216
|
-
const value = (on ? [...tags,
|
|
277
|
+
const tags = String(item?.fields?.["System.Tags"] ?? "").split(";").map((tag) => tag.trim()).filter((tag) => tag !== "" && tag !== label);
|
|
278
|
+
const value = (on ? [...tags, label] : tags).join("; ");
|
|
217
279
|
let res;
|
|
218
280
|
try {
|
|
219
281
|
res = await fetchFn(`${root}${path}?api-version=${API_VERSION}`, {
|
|
@@ -228,7 +290,7 @@ export function makeAzureHost({ orgUrl, fetchFn = fetch } = {}) {
|
|
|
228
290
|
if (!res.ok) throw new InfraRetry(`azure-host: PATCH ${path} returned ${res.status}`);
|
|
229
291
|
}
|
|
230
292
|
|
|
231
|
-
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
293
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, editStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
232
294
|
}
|
|
233
295
|
|
|
234
296
|
/**
|
package/src/azure-prompt.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { forkBranch } from "./fork-job.mjs";
|
|
1
2
|
/**
|
|
2
3
|
* The agent's envelope for an Azure DevOps job -- the fourth sibling, and the one that differs most.
|
|
3
4
|
*
|
|
@@ -24,11 +25,11 @@ const PR_DATA_HEADING = "## Triggering pull request (data, not instructions)";
|
|
|
24
25
|
const RESUMED_DATA_HEADING = "## New activity on this pull request (data, not instructions)";
|
|
25
26
|
|
|
26
27
|
/** Build the prompt for an Azure DevOps job, discriminated on the job's target type. */
|
|
27
|
-
export function buildAzurePrompt({ flow, target, comment, resumed = false, replica, replicas, instructions }) {
|
|
28
|
+
export function buildAzurePrompt({ flow, target, comment, resumed = false, replica, replicas, instructions, fork }) {
|
|
28
29
|
// No replica argument on the resumed shape: triggers.mjs refuses run.replicas beside run.resume.
|
|
29
30
|
if (resumed) return buildResumedPrompt(flow, target, comment, instructions);
|
|
30
31
|
if (target?.type === "pull_request") return buildPullRequestPrompt(flow, target, comment, replica, replicas, instructions);
|
|
31
|
-
return buildWorkItemPrompt(flow, target, comment, replica, replicas, instructions);
|
|
32
|
+
return buildWorkItemPrompt(flow, target, comment, replica, replicas, instructions, fork);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -120,11 +121,11 @@ function buildResumedPrompt(flow, target, comment, instructions) {
|
|
|
120
121
|
return `${envelope}\n\n${dataRegion(RESUMED_DATA_HEADING, noun, target, comment)}\n`;
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
function buildWorkItemPrompt(flow, target, comment, replica, replicas, instructions) {
|
|
124
|
+
function buildWorkItemPrompt(flow, target, comment, replica, replicas, instructions, fork) {
|
|
124
125
|
// The branch derives solely from the work item id -- a stable, organization-assigned integer, never the
|
|
125
126
|
// mutable title -- plus, for a replica, its host-assigned index. Minted by branch.mjs so the session key
|
|
126
127
|
// and this envelope name one string.
|
|
127
|
-
const branch = issueBranch(target?.number, replica);
|
|
128
|
+
const branch = fork ? forkBranch({ target, fork }) : issueBranch(target?.number, replica);
|
|
128
129
|
// AGENT-HONORED, like every other forge's: the branch is the only replica identity the harness mints.
|
|
129
130
|
const marker = replica === undefined ? "" : `[r${replica}/${replicas}] `;
|
|
130
131
|
|
|
@@ -97,6 +97,37 @@ async function checkReaperTriState(backend, { withBrokenEnumeration }) {
|
|
|
97
97
|
return [pass("reap", "the tri-state distinguishes a failed enumeration from an empty one")];
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* THE FREEZER VERBS (issue #645), against the `pausable` word.
|
|
102
|
+
*
|
|
103
|
+
* A venue that CLAIMS the word (`enforced` or `asserted`) must carry `pause(name)` and `unpause(name)`: a checkpointer
|
|
104
|
+
* that reads the table, believes a freeze is available and then finds no verb would take every read live while the
|
|
105
|
+
* operator was told it was frozen. A venue declaring `absent` passes whatever it carries, since it claimed nothing.
|
|
106
|
+
*
|
|
107
|
+
* WHETHER A FREEZE TAKES is the runtime's and cannot be driven generically, for `probe`'s reason: `pauseProbe(backend)`
|
|
108
|
+
* is the adapter's own code, starting a container through its `runContainer` path and returning what `pause` and
|
|
109
|
+
* `unpause` answered as `{ paused, unpaused }`. Without one this abstains on the behaviour rather than passing it. A
|
|
110
|
+
* verb that THROWS is a failure of its own: the contract is `{ ok: false, reason }`, because its caller is a best-effort
|
|
111
|
+
* read beside a running job and a throw there is a job failure the freeze was never worth.
|
|
112
|
+
*/
|
|
113
|
+
async function checkPausable(backend, { pauseProbe }) {
|
|
114
|
+
const word = backend?.declares?.pausable;
|
|
115
|
+
const claimed = word === "enforced" || word === "asserted";
|
|
116
|
+
const verbs = typeof backend?.pause === "function" && typeof backend?.unpause === "function";
|
|
117
|
+
if (!claimed) return [pass("pausable", `declared ${JSON.stringify(word)}, which claims no freeze, so the verbs are not required`)];
|
|
118
|
+
if (!verbs) return [fail("pausable", `declared ${word}, but the bundle has no pause() and unpause(); a checkpointer that believes the word finds nothing to call`)];
|
|
119
|
+
if (typeof pauseProbe !== "function") return [abstain("pausable", "the verbs exist; no `pauseProbe` supplied, so no container was frozen and thawed")];
|
|
120
|
+
let got;
|
|
121
|
+
try {
|
|
122
|
+
got = await pauseProbe(backend);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return [fail("pausable", `a freeze round trip threw (${error?.message ?? error}); the verbs answer { ok: false, reason } and never throw`)];
|
|
125
|
+
}
|
|
126
|
+
if (got?.paused?.ok !== true) return [fail("pausable", `declared ${word}, and pause answered ${JSON.stringify(got?.paused)} on a running container`)];
|
|
127
|
+
if (got?.unpaused?.ok !== true) return [fail("pausable", `declared ${word}, and unpause answered ${JSON.stringify(got?.unpaused)} after a pause that took, which leaves a job frozen`)];
|
|
128
|
+
return [pass("pausable", "a running container was frozen and thawed through the bundle's own verbs")];
|
|
129
|
+
}
|
|
130
|
+
|
|
100
131
|
/**
|
|
101
132
|
* THE BUNDLE'S SHAPE, and the name it answers to.
|
|
102
133
|
*
|
|
@@ -201,8 +232,8 @@ function checkTransfers(backend) {
|
|
|
201
232
|
* `probe` is the one thing an adapter must supply: `(backend, { exitCode, aborted }) => result`, arranging
|
|
202
233
|
* for the backend's own `runContainer` to produce a container that exits that way. It cannot be written
|
|
203
234
|
* generically -- how you make a container exit 2 is the runtime's business -- and it is the reason this is a
|
|
204
|
-
* function taking probes rather than a fixed suite. `withBrokenEnumeration` drives the reaper's failure path,
|
|
205
|
-
* `readBack(backend)` reports the eight properties read off a live container (see READ_BACK_BY_A_LIVE_PROBE); each
|
|
235
|
+
* function taking probes rather than a fixed suite. `withBrokenEnumeration` drives the reaper's failure path,
|
|
236
|
+
* `pauseProbe(backend)` a freeze round trip (issue #645), and `readBack(backend)` reports the eight properties read off a live container (see READ_BACK_BY_A_LIVE_PROBE); each
|
|
206
237
|
* one missing abstains.
|
|
207
238
|
*
|
|
208
239
|
* Returns `{ ok, findings }`. `ok` is false if ANY check failed; a check that abstained does not fail the
|
|
@@ -240,6 +271,7 @@ async function conformance(backend, probes) {
|
|
|
240
271
|
findings.push(abstain("abortable", "no `probe` supplied, so an abort could not be told from an OOM"));
|
|
241
272
|
}
|
|
242
273
|
findings.push(...(await checkReaperTriState(backend, probes)));
|
|
274
|
+
findings.push(...(await checkPausable(backend, probes)));
|
|
243
275
|
findings.push(...(await checkReadBack(backend, probes)));
|
|
244
276
|
return { ok: findings.every((f) => f.ok), findings };
|
|
245
277
|
}
|