toolroll 0.9.77 → 0.9.78

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/agent-fence.js +18 -2
  3. package/dist/backup-ui.d.ts +9 -1
  4. package/dist/backup-ui.js +28 -3
  5. package/dist/backup.js +4 -3
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +18 -18
  8. package/dist/browser-workspace.d.ts +2 -0
  9. package/dist/builder.d.ts +4 -3
  10. package/dist/builder.js +14 -8
  11. package/dist/check-failure.js +31 -3
  12. package/dist/check-report.d.ts +18 -3
  13. package/dist/check-report.js +67 -7
  14. package/dist/claim.d.ts +9 -0
  15. package/dist/claim.js +9 -1
  16. package/dist/contracts/console-api.d.ts +1 -1
  17. package/dist/contracts/console-api.js +1 -1
  18. package/dist/contracts/release-terms.d.ts +41 -0
  19. package/dist/contracts/release-terms.js +53 -0
  20. package/dist/controller-service.js +4 -1
  21. package/dist/deploy-rescue.d.ts +41 -0
  22. package/dist/deploy-rescue.js +94 -0
  23. package/dist/desktop-update.d.ts +1 -1
  24. package/dist/dispose.js +4 -3
  25. package/dist/evidence.d.ts +9 -0
  26. package/dist/evidence.js +15 -3
  27. package/dist/exec.d.ts +2 -0
  28. package/dist/exec.js +15 -1
  29. package/dist/guides.js +18 -8
  30. package/dist/latency-timeline.d.ts +46 -0
  31. package/dist/latency-timeline.js +120 -0
  32. package/dist/live-backup.d.ts +39 -0
  33. package/dist/live-backup.js +271 -0
  34. package/dist/operate.js +203 -16
  35. package/dist/planner.js +2 -2
  36. package/dist/release-adapters.d.ts +32 -1
  37. package/dist/release-adapters.js +99 -7
  38. package/dist/release-carry.d.ts +21 -1
  39. package/dist/release-carry.js +22 -0
  40. package/dist/release-cli.d.ts +3 -0
  41. package/dist/release-cli.js +74 -5
  42. package/dist/release-gate.d.ts +5 -0
  43. package/dist/release-gate.js +1 -1
  44. package/dist/release-recheck.js +3 -2
  45. package/dist/release.d.ts +37 -4
  46. package/dist/release.js +128 -43
  47. package/dist/runner-process.d.ts +67 -0
  48. package/dist/runner-process.js +219 -0
  49. package/dist/runner.d.ts +48 -6
  50. package/dist/runner.js +71 -8
  51. package/dist/scope.d.ts +19 -1
  52. package/dist/scope.js +19 -2
  53. package/dist/scout.js +2 -2
  54. package/dist/serve.js +11 -0
  55. package/dist/server/http.d.ts +2 -0
  56. package/dist/server/pages.js +7 -3
  57. package/dist/server/remote-hooks.d.ts +1 -1
  58. package/dist/server/remote-hooks.js +5 -3
  59. package/dist/server/render-results.d.ts +2 -0
  60. package/dist/server/render-results.js +1 -0
  61. package/dist/server/request-context.d.ts +2 -0
  62. package/dist/server/settings.js +22 -2
  63. package/dist/server/tasks.js +4 -1
  64. package/dist/storage-sweep.js +1 -1
  65. package/dist/store.d.ts +125 -2
  66. package/dist/store.js +250 -12
  67. package/dist/surface.js +3 -1
  68. package/dist/teardown-git.js +5 -5
  69. package/dist/toolroll-update.js +2 -2
  70. package/dist/up-role-child.js +6 -2
  71. package/dist/workspace-revision.js +3 -1
  72. package/package.json +1 -1
@@ -804,6 +804,8 @@ export type BrowserResultView = {
804
804
  efficiency?: import("./run-efficiency.js").RunEfficiency | null;
805
805
  /** What its network was and what its proxy saw (egress.ts): display only. */
806
806
  network?: import("./egress.js").RunNetworkRecord | null;
807
+ /** Where the time went: its release check's parts and the task's deploys (latency-timeline.ts), display only. */
808
+ timing?: import("./latency-timeline.js").TimingView | null;
807
809
  /** Words for the result's Accept: Accept and finish when offered, else the Needs you acceptance. */
808
810
  decision: BrowserResultDecision | null;
809
811
  checks: {
package/dist/builder.d.ts CHANGED
@@ -174,10 +174,11 @@ export declare const REPAIR_MAX_TURNS = 4;
174
174
  export declare const REPAIR_TIMEOUT_MS: number;
175
175
  /**
176
176
  * How often a running build says "still here" — extending its lease and its
177
- * runner's liveness in one beat. A minute against a three-minute liveness
178
- * window means two beats can be lost to load before anything looks dead.
177
+ * runner's liveness in one beat. Every 15 s against a one-minute liveness
178
+ * window (runner.ts) means three beats can be lost to load before anything
179
+ * looks dead.
179
180
  */
180
- export declare const DEFAULT_PULSE_MS = 60000;
181
+ export declare const DEFAULT_PULSE_MS = 15000;
181
182
  /** Branches an unattended agent may never commit to, whatever it was asked. */
182
183
  export declare const PROTECTED: Set<string>;
183
184
  /**
package/dist/builder.js CHANGED
@@ -56,12 +56,13 @@ import { witnessedRunner } from "./process-custody.js";
56
56
  import { runWithIsolatedDatabase } from "./child-database.js";
57
57
  import { recordWorktreeProcess, saveWorkPatch } from "./worktree.js";
58
58
  import { launchSandboxOf, protectedChanges, protectedLinks } from "./agent-fence.js";
59
- import { approvalOf, digestOf, idleClock, isAcpProfile, profileDigestOf, routeParityProblem, watchdogsOf } from "./scope.js";
59
+ import { approvalOf, digestOf, idleClock, isAcpProfile, profileDigestOf, releaseCheckScope, routeParityProblem, watchdogsOf } from "./scope.js";
60
60
  import { legOf, routeDigestOf, routeFromJson } from "./phase-routing.js";
61
61
  import { execFileSync } from "node:child_process";
62
62
  import { currentClaim, heartbeat, SYNC_MAX_AGE_MS, finalize } from "./claim.js";
63
63
  import { missingCapability } from "./dispatch.js";
64
- import { heartbeat as runnerHeartbeat } from "./runner.js";
64
+ import { heartbeat as runnerHeartbeat, RUNNER_HEARTBEAT_MS } from "./runner.js";
65
+ import { keepCheckTimeline } from "./latency-timeline.js";
65
66
  import { MARKER as LEASE_MARKER } from "./worktree.js";
66
67
  import { parseDecision, parseHandoff, repairPrompt, HEADLESS_RULE, HANDOFF_CONCLUSION_CAP, HANDOFF_ITEM_CAP, HANDOFF_LIST_CAP, HANDOFF_PAYLOAD_CAP } from "./decision.js";
67
68
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -191,10 +192,11 @@ export const REPAIR_MAX_TURNS = 4;
191
192
  export const REPAIR_TIMEOUT_MS = 5 * 60_000;
192
193
  /**
193
194
  * How often a running build says "still here" — extending its lease and its
194
- * runner's liveness in one beat. A minute against a three-minute liveness
195
- * window means two beats can be lost to load before anything looks dead.
195
+ * runner's liveness in one beat. Every 15 s against a one-minute liveness
196
+ * window (runner.ts) means three beats can be lost to load before anything
197
+ * looks dead.
196
198
  */
197
- export const DEFAULT_PULSE_MS = 60_000;
199
+ export const DEFAULT_PULSE_MS = RUNNER_HEARTBEAT_MS;
198
200
  /** Branches an unattended agent may never commit to, whatever it was asked. */
199
201
  export const PROTECTED = new Set(["main", "master", "trunk", "develop", "release"]);
200
202
  const GIT = "git";
@@ -378,12 +380,13 @@ export function proveApprovedProfile(scope, given) {
378
380
  acceptance: scope.acceptance, candidate: scope.candidate ?? null,
379
381
  qualityMode: scope.qualityMode ?? "default",
380
382
  releaseTerms: scope.releaseTerms ?? null,
383
+ releaseCheck: scope.releaseCheck ?? null,
381
384
  }, snapshot,
382
385
  // v47: the SEALED route is part of the signed bytes whenever it
383
386
  // says more than the legacy resolution — re-derived here from
384
387
  // the seal's own snapshot, never from mutable configuration.
385
388
  routeFromJson(scope.approvedRouteJson ?? null))
386
- : digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, releaseTerms: scope.releaseTerms ?? null });
389
+ : digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, releaseTerms: scope.releaseTerms ?? null, releaseCheck: scope.releaseCheck ?? null });
387
390
  if (rederived !== scope.approvedDigest) {
388
391
  return { ok: false, message: "the approval record does not verify against the stored terms — re-approve (stale-approval)" };
389
392
  }
@@ -2372,7 +2375,8 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2372
2375
  const access = checkLaunchOf(store, runId, captured.effective.profile).access;
2373
2376
  const decision = planTargetedRecheck(store, root, { runId, taskId: request.taskId, repo, head: sealedHead, command: configured, access, now: now() });
2374
2377
  // A release's check (signed terms or an open authority): CI never stands in for its unit suite.
2375
- const release = decision.authority !== null || (store.getScope(request.taskId)?.releaseTerms ?? null) !== null;
2378
+ // A release's check: signed terms, a release check purpose (pipeline-speed.md, P0) or an open authority.
2379
+ const release = decision.authority !== null || releaseCheckScope(store.getScope(request.taskId));
2376
2380
  try {
2377
2381
  checkAttempt = prepareCheckAttempt(store, root, { runId, repo, head: sealedHead, command: configured, access, authority: decision.authority,
2378
2382
  ...(decision.mode === "targeted" ? { plan: decision.plan, snapshot: decision.snapshot } : {}), watch: decision.watch, release }, now());
@@ -2618,6 +2622,8 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2618
2622
  let sealedReport = null;
2619
2623
  if (checkAttempt !== null && last !== null && !last.notFound) {
2620
2624
  sealedReport = sealCheckOutcome(store, root, checkAttempt, { stdout: last.stdout, exitCode: last.timedOut ? null : last.code }, now());
2625
+ // Where the check's time went (latency-timeline.ts): each part's start and end, display only.
2626
+ keepCheckTimeline(store, { runId, taskId: request.taskId, report: sealedReport, stdout: last.stdout, now: now() });
2621
2627
  }
2622
2628
  // What the check covered (receipt version 3): full, composed from the release's last full check, partial, or
2623
2629
  // identity-removed (which stops the release carrying).
@@ -2630,7 +2636,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2630
2636
  verifyCommand = effective.result;
2631
2637
  releaseFacts = effective.facts;
2632
2638
  }
2633
- else if (verifyCommand.configured && verifyCommand.ran && chosenCheck?.level === "full" && (store.getScope(request.taskId)?.releaseTerms ?? null) !== null) {
2639
+ else if (verifyCommand.configured && verifyCommand.ran && chosenCheck?.level === "full" && releaseCheckScope(store.getScope(request.taskId))) {
2634
2640
  // A release's Full check that couldn't be sealed before it ran (no nonce, no inventory) has no result it can count.
2635
2641
  verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: "no-result" };
2636
2642
  recordCheckNote("The release check ran without a sealed attempt (its commit couldn't be read before it ran), so it has no result that can count.");
@@ -22,6 +22,7 @@ import { CHECK_REPORT_CAPTURE, readSealedJson } from "./check-report.js";
22
22
  import { readCheckReport } from "./contracts/release-check.js";
23
23
  import { regateTask } from "./dispose.js";
24
24
  import { INFRA_RERUNS_PER_CANDIDATE, INFRA_RERUNS_PER_INCIDENT } from "./release.js";
25
+ import { releaseCheckScope } from "./scope.js";
25
26
  import { verificationEvidence } from "./verification-evidence.js";
26
27
  /** The plane's limits for confirmed infrastructure failures (one rerun per incident, two per candidate). */
27
28
  export { INFRA_RERUNS_PER_CANDIDATE, INFRA_RERUNS_PER_INCIDENT };
@@ -235,10 +236,37 @@ export const classWords = (kind) => ({
235
236
  export function recheckReleaseTask(store, root, taskId, now) {
236
237
  const ref = store.lookupRef(taskId);
237
238
  const scope = store.getScope(taskId);
238
- if (ref === null || scope === null || (scope.releaseTerms ?? null) === null || (scope.candidate ?? null) === null)
239
+ if (ref === null || scope === null || !releaseCheckScope(scope) || (scope.candidate ?? null) === null)
239
240
  return { action: "stop", class: null, why: `${taskId} isn't a release task`, source: null };
240
- // One transaction: the rerun and its ledger row land together, or neither does.
241
- return store.transact(() => recheckInside(store, root, taskId, ref.repo, String(scope.candidate), now));
241
+ // One transaction: the binding, the rerun and its ledger row are read and land together, or nothing does.
242
+ return store.transact(() => {
243
+ const bound = releasePurposeBinding(store, store.getScope(taskId) ?? scope, ref.repo);
244
+ if (bound !== null)
245
+ return { action: "stop", class: null, why: bound, source: null };
246
+ return recheckInside(store, root, taskId, ref.repo, String(scope.candidate), now);
247
+ });
248
+ }
249
+ /**
250
+ * Why the release's sealed purpose no longer names this exact check, or null when it does (pipeline-speed.md, P0). A
251
+ * release check purpose (a release that can't carry) authorizes a rerun only of the identical approved candidate: its
252
+ * sealed commit is the scope's candidate, its project is the task's, the project's live release check grant is the one
253
+ * sealed and its check access is the one sealed. Signed terms keep their own rules (a carried commit's candidate is
254
+ * not the terms' first commit) and are held to the same project and grant.
255
+ */
256
+ function releasePurposeBinding(store, scope, repo) {
257
+ const sealed = scope.releaseTerms ?? scope.releaseCheck ?? null;
258
+ if (sealed === null)
259
+ return "it carries no release terms or release check purpose";
260
+ if (repo === null || sealed.repo !== repo)
261
+ return "its sealed release names another project";
262
+ if (scope.releaseTerms == null && scope.releaseCheck.approvedSha !== scope.candidate)
263
+ return "its sealed release check names another commit";
264
+ const grant = store.liveVerifyCommand(repo);
265
+ if (grant === null || grant.id !== sealed.grant.id || grant.digest !== sealed.grant.digest)
266
+ return "the project's release check changed since the approval";
267
+ if (scope.releaseTerms == null && store.projectCheckAccess(repo) !== scope.releaseCheck.access)
268
+ return "the project's check access changed since the approval";
269
+ return null;
242
270
  }
243
271
  function recheckInside(store, root, taskId, repo, candidate, now) {
244
272
  const ref = store.lookupRef(taskId);
@@ -52,8 +52,8 @@ export declare const FULL_PARTS: readonly CheckPart[];
52
52
  export declare const NOTHING_PATHS: readonly RegExp[];
53
53
  /** Model-facing code, whose change runs the real-model journeys: the plane's copy of scripts/release-check.mjs MODEL. */
54
54
  export declare const MODEL_PATHS: readonly RegExp[];
55
- /** Whether a changed path can affect an upgrade: the plane's copy of scripts/release-check.mjs needsUpgrade, without its
56
- * version-only exception (the plane counts a version bump as a change, so it owes more, never less). */
55
+ /** Whether a changed path can affect an upgrade: the plane's copy of scripts/release-check.mjs needsUpgrade. Its
56
+ * version-only exception is decided apart, from verified blobs (versionOnlyChange), never from the path alone. */
57
57
  export declare function upgradeRelevant(file: string): boolean;
58
58
  /** The paths that differ between two verified trees: added, removed, or with another mode or blob. */
59
59
  export declare function changedPaths(facts: TreeFacts, base: TreeFacts): string[];
@@ -65,7 +65,10 @@ export declare function changedPaths(facts: TreeFacts, base: TreeFacts): string[
65
65
  * changed; the upgrade path when a change can affect an upgrade and the tree has it. With no base every one the tree
66
66
  * can run is owed. The check is told these on its handshake and runs at least them.
67
67
  */
68
- export declare function fullObligationsOf(facts: TreeFacts, base: TreeFacts | null): CheckPart[];
68
+ export declare function fullObligationsOf(facts: TreeFacts, base: TreeFacts | null,
69
+ /** package.json / package-lock.json paths whose change since `base` is only the project's own version, proved by the
70
+ * plane from both verified blobs (versionBumpsOf): such a change alone doesn't owe the upgrade path. */
71
+ versionBumps?: readonly string[]): CheckPart[];
69
72
  /** What a check must account for, sealed before it runs: every unit file of the tree, whatever runs (a targeted
70
73
  * check accounts for the rest through its base), and the parts it owes (a full check's from fullObligationsOf), and
71
74
  * whether CI may stand in for its unit suite. */
@@ -153,3 +156,15 @@ export declare function fullCheckProblem(input: {
153
156
  inventory: SealedInventory | null;
154
157
  exitCode: number | null;
155
158
  }): string | null;
159
+ /** Where a release bumps the project's own version: the plane's copy of scripts/release-check.mjs VERSIONED. */
160
+ export declare const VERSIONED_PATHS: readonly string[];
161
+ /**
162
+ * Whether `path` changed from `before` to `after` only in the project's own version (pipeline-speed.md, item 4): the
163
+ * plane's copy of scripts/release-check.mjs versionOnly — the top-level "version", and in the lockfile the root
164
+ * package's. Anything else (a dependency, a script, a reordering, a duplicate key, a file added or removed, bytes that
165
+ * aren't one JSON object) is a real change.
166
+ */
167
+ export declare function versionOnlyChange(path: string, before: Buffer | null, after: Buffer | null): boolean;
168
+ /** The versioned files whose change from `base` to `facts` is only the version, read from verified blobs in the
169
+ * project's own Git directory. Anything unreadable is a real change (owes more, never less). */
170
+ export declare function versionBumpsOf(gitDir: string, base: TreeFacts, facts: TreeFacts): string[];
@@ -20,7 +20,7 @@
20
20
  import { createHash, randomBytes } from "node:crypto";
21
21
  import { lstatSync, realpathSync } from "node:fs";
22
22
  import { join } from "node:path";
23
- import { readVerifiedCommit, verifiedTreeEntries } from "./commit-extract.js";
23
+ import { readVerifiedCommit, verifiedBlobs, verifiedTreeEntries } from "./commit-extract.js";
24
24
  import { CHECK_PARTS, CHECK_REPORT_MAX_BYTES, CHECK_REPORT_PREFIX, readCheckReport, } from "./contracts/release-check.js";
25
25
  import { readVerifiedArtifact, storeEvidence } from "./evidence.js";
26
26
  import { releaseBaselineRecord } from "./release-card.js";
@@ -107,8 +107,8 @@ export const MODEL_PATHS = Object.freeze([
107
107
  /^src\/(subagents|subagent-work|task-sizing|flow-sort|flow-draft)\.ts$/,
108
108
  /^src\/(provider|provider-[a-z-]+|invoke|exec|attest|coding-provider|assignment-adapters|subscription-[a-z-]+)\.ts$/,
109
109
  ]);
110
- /** Whether a changed path can affect an upgrade: the plane's copy of scripts/release-check.mjs needsUpgrade, without its
111
- * version-only exception (the plane counts a version bump as a change, so it owes more, never less). */
110
+ /** Whether a changed path can affect an upgrade: the plane's copy of scripts/release-check.mjs needsUpgrade. Its
111
+ * version-only exception is decided apart, from verified blobs (versionOnlyChange), never from the path alone. */
112
112
  export function upgradeRelevant(file) {
113
113
  if (/^(docs|evidence|design|output)\//.test(file) || /\.md$/.test(file) || file === "LICENSE")
114
114
  return false;
@@ -134,7 +134,10 @@ export function changedPaths(facts, base) {
134
134
  * changed; the upgrade path when a change can affect an upgrade and the tree has it. With no base every one the tree
135
135
  * can run is owed. The check is told these on its handshake and runs at least them.
136
136
  */
137
- export function fullObligationsOf(facts, base) {
137
+ export function fullObligationsOf(facts, base,
138
+ /** package.json / package-lock.json paths whose change since `base` is only the project's own version, proved by the
139
+ * plane from both verified blobs (versionBumpsOf): such a change alone doesn't owe the upgrade path. */
140
+ versionBumps = []) {
138
141
  // What the tree can run is read from the candidate AND the last release (security scan): a candidate that deletes a
139
142
  // journey script, the upgrade path or its unit files doesn't take away what was owed at the base — the part stays
140
143
  // owed, and a check that can't run it has no result. Any entry counts, a symlink included, never only a regular file.
@@ -146,7 +149,8 @@ export function fullObligationsOf(facts, base) {
146
149
  const code = changed?.filter(path => !NOTHING_PATHS.some(re => re.test(path))) ?? null;
147
150
  const checks = code === null || code.length > 0;
148
151
  const real = code === null || code.some(path => !/\.test\.[cm]?[jt]s$/.test(path) && MODEL_PATHS.some(re => re.test(path)));
149
- const upgrade = has("scripts/upgrade-path.mjs") && (changed === null || changed.some(upgradeRelevant));
152
+ const bumped = (path) => VERSIONED_PATHS.includes(path) && versionBumps.includes(path);
153
+ const upgrade = has("scripts/upgrade-path.mjs") && (changed === null || changed.some(path => !bumped(path) && upgradeRelevant(path)));
150
154
  return [
151
155
  "typecheck", "build",
152
156
  ...(checks && units ? ["unit"] : []),
@@ -195,7 +199,7 @@ export function prepareCheckAttempt(store, root, input, now) {
195
199
  }
196
200
  baseFacts = baseRead.facts;
197
201
  }
198
- const owed = fullObligationsOf(read.facts, baseFacts);
202
+ const owed = fullObligationsOf(read.facts, baseFacts, baseFacts === null ? [] : versionBumpsOf(gitDir, baseFacts, read.facts));
199
203
  const release = input.release !== false;
200
204
  const ciSubstitute = !release;
201
205
  const inventory = inventoryOf(read.facts, plan, { owed, release });
@@ -213,7 +217,9 @@ export function prepareCheckAttempt(store, root, input, now) {
213
217
  inventoryDigest: payloadDigest(inventory), contextDigest: payloadDigest(context), nonceHash: sha256(nonce),
214
218
  }, now);
215
219
  // The command's own limit rides along, so the check ends itself (logs and report printed) before the plane stops it.
216
- const stdin = JSON.stringify({ nonce, mode, plan, watch: [...(input.watch ?? [])], limitMs: input.command.timeoutMs, ...(plan === null ? { owed, ciSubstitute } : {}) });
220
+ // `reportBytes`: the largest report this plane reads back once sealed. A plane that doesn't say (0.9.77 and older) reads
221
+ // only 64 KiB, so the check gives it no larger report rather than one that poisons the receipt (scripts/release-check.mjs).
222
+ const stdin = JSON.stringify({ nonce, mode, plan, watch: [...(input.watch ?? [])], limitMs: input.command.timeoutMs, reportBytes: CHECK_ARTIFACT_CAP, ...(plan === null ? { owed, ciSubstitute } : {}) });
217
223
  return { runId: input.runId, nonce, stdin, mode, inventory, context, plan, snapshot, watch: [...(input.watch ?? [])] };
218
224
  }
219
225
  /** Seal one JSON payload as a structured-output artifact under its capture label. */
@@ -388,3 +394,57 @@ export function fullCheckProblem(input) {
388
394
  }
389
395
  return null;
390
396
  }
397
+ /** Where a release bumps the project's own version: the plane's copy of scripts/release-check.mjs VERSIONED. */
398
+ export const VERSIONED_PATHS = Object.freeze(["package.json", "package-lock.json"]);
399
+ /**
400
+ * Whether `path` changed from `before` to `after` only in the project's own version (pipeline-speed.md, item 4): the
401
+ * plane's copy of scripts/release-check.mjs versionOnly — the top-level "version", and in the lockfile the root
402
+ * package's. Anything else (a dependency, a script, a reordering, a duplicate key, a file added or removed, bytes that
403
+ * aren't one JSON object) is a real change.
404
+ */
405
+ export function versionOnlyChange(path, before, after) {
406
+ if (!VERSIONED_PATHS.includes(path) || before === null || after === null)
407
+ return false;
408
+ const rest = (bytes) => {
409
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
410
+ const json = JSON.parse(text);
411
+ // Parsing is lossy for duplicate keys and rounded numbers. Such bytes never earn an exemption.
412
+ const tokens = (value) => JSON.stringify(value.match(/"(?:\\.|[^"\\])*"|[^\s"{}\[\]:,]+|[{}\[\]:,]/g));
413
+ if (tokens(text) !== tokens(JSON.stringify(json)))
414
+ throw new Error("ambiguous JSON");
415
+ if (json === null || typeof json !== "object" || Array.isArray(json))
416
+ throw new Error("not an object");
417
+ const object = json;
418
+ if (typeof object["version"] !== "string")
419
+ throw new Error("not a version string");
420
+ delete object["version"];
421
+ const packages = object["packages"];
422
+ if (path === "package-lock.json" && packages !== null && typeof packages === "object" && !Array.isArray(packages)) {
423
+ const root = packages[""];
424
+ if (root !== null && typeof root === "object" && !Array.isArray(root)) {
425
+ if (typeof root["version"] !== "string")
426
+ throw new Error("not a root version string");
427
+ delete root["version"];
428
+ }
429
+ }
430
+ return JSON.stringify(object);
431
+ };
432
+ try {
433
+ return rest(before) === rest(after);
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ }
439
+ /** The versioned files whose change from `base` to `facts` is only the version, read from verified blobs in the
440
+ * project's own Git directory. Anything unreadable is a real change (owes more, never less). */
441
+ export function versionBumpsOf(gitDir, base, facts) {
442
+ const before = verifiedBlobs(gitDir, base.tree, VERSIONED_PATHS);
443
+ const after = verifiedBlobs(gitDir, facts.tree, VERSIONED_PATHS);
444
+ if (!before.ok || !after.ok)
445
+ return [];
446
+ return VERSIONED_PATHS.filter(path => {
447
+ const a = before.value.get(path), b = after.value.get(path);
448
+ return a !== undefined && b !== undefined && a.id !== b.id && versionOnlyChange(path, a.bytes, b.bytes);
449
+ });
450
+ }
package/dist/claim.d.ts CHANGED
@@ -103,6 +103,15 @@ export type AcquireResult = {
103
103
  | {
104
104
  ok: false;
105
105
  reason: "coordinator-filed";
106
+ } | {
107
+ ok: false;
108
+ reason: "watch-custody";
109
+ }
110
+ /** The runner reported draining (v137 lifecycle): its stop has begun, so it takes no new work. */
111
+ | {
112
+ ok: false;
113
+ reason: "draining";
114
+ until: string;
106
115
  };
107
116
  /** `fenced` means the lease was superseded; `unknown` means it never existed. */
108
117
  export type FenceResult = {
package/dist/claim.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * rather than probable.
29
29
  */
30
30
  import { randomUUID } from "node:crypto";
31
- import { authenticate } from "./runner.js";
31
+ import { authenticate, drainOf } from "./runner.js";
32
32
  import { pauseForAuth } from "./provider-auth.js";
33
33
  import { DEFAULT_MAX_OPEN_DECISIONS, missingCapability, scopeApprovedForDispatch, taskReadinessBlocker, } from "./dispatch.js";
34
34
  import { BUILT_IN, } from "./store.js";
@@ -85,6 +85,10 @@ function acquireLocked(store, taskRef, runner, options) {
85
85
  const gate = authenticate(store, runner, options.token);
86
86
  if (!gate.ok)
87
87
  return { ok: false, reason: "unauthenticated", detail: gate.reason };
88
+ // DRAINING (v137): a runner whose stop has begun admits nothing new, on every claim road.
89
+ const drain = drainOf(store, gate.runner, now);
90
+ if (drain !== null)
91
+ return { ok: false, reason: "draining", until: drain.until };
88
92
  const placedRef = store.refForId(taskRef);
89
93
  const placedRepo = placedRef?.repo ?? null;
90
94
  if (placedRepo === null)
@@ -92,6 +96,10 @@ function acquireLocked(store, taskRef, runner, options) {
92
96
  if (!gate.runner.repos.includes(placedRepo)) {
93
97
  return { ok: false, reason: "unauthorized-repo", repo: placedRepo };
94
98
  }
99
+ // The watch may have lapsed while this pass awaited I/O, before its heartbeat callback ran. Runner tokens
100
+ // survive a role restart, so prove this exact watch in the claim transaction as well.
101
+ if (options.incarnation !== undefined && db.prepare("SELECT 1 FROM watch_lease WHERE runner = ? AND repo = ? AND owner = ? AND expires_at > ?").get(runner, placedRepo, options.incarnation, stamp) === undefined)
102
+ return { ok: false, reason: "watch-custody" };
95
103
  // THE COORDINATOR QUARANTINE in the one primitive every claim road
96
104
  // shares (MCP spec v6, round-3 f1): a coordinator-filed task whose
97
105
  // scope has not been sealed by the password ceremony claims for
@@ -98,7 +98,7 @@ export declare const CONSOLE_FORMS: {
98
98
  readonly storage: FormContract<"cleanup" | "password" | "path" | "preview">;
99
99
  readonly pullRequests: FormContract<"act" | "base" | "github" | "method" | "password" | "repo" | "when-green">;
100
100
  readonly checks: FormContract<"act" | "command" | "level" | "on" | "password" | "repo" | "timeout">;
101
- readonly backups: FormContract<"every" | "folder" | "keep" | "password">;
101
+ readonly backups: FormContract<"every" | "folder" | "keep" | "live_every" | "live_folder" | "password">;
102
102
  readonly data: FormContract<"password">;
103
103
  readonly flowsOn: FormContract<"repo" | "starter">;
104
104
  readonly integrationsTest: FormContract<"key">;
@@ -107,7 +107,7 @@ export const CONSOLE_FORMS = {
107
107
  storage: formContract("POST /settings/storage, /settings/storage/clean, /settings/storage/discard", ["password", "cleanup", "preview", "path"]),
108
108
  pullRequests: formContract("POST /settings/pull-requests", ["repo", "act", "password", "github", "base", "method", "when-green"]),
109
109
  checks: formContract("POST /settings/checks", ["repo", "password", "act", "on", "level", "command", "timeout"]),
110
- backups: formContract("POST /settings/backups", ["password", "every", "keep", "folder"]),
110
+ backups: formContract("POST /settings/backups", ["password", "every", "keep", "folder", "live_every", "live_folder"]),
111
111
  data: formContract("POST /settings/data", ["password"]),
112
112
  flowsOn: formContract("POST /settings/flows/on", ["repo", "starter"]),
113
113
  integrationsTest: formContract("POST /settings/integrations/test", ["key"]),
@@ -64,3 +64,44 @@ export declare function storedReleaseTerms(raw: unknown): {
64
64
  ok: false;
65
65
  problem: string;
66
66
  };
67
+ /**
68
+ * A release check's purpose (docs/plans/pipeline-speed.md, P0): what a person agrees to when they approve a release
69
+ * whose later commits can't carry (its checks run outside the sandbox, or carrying is off) — this exact candidate is a
70
+ * release's check. It never carries a later commit, never lets CI stand in for the check's units and never approves
71
+ * itself; it lets the plane hold the check to the release's strict report rules and rerun it, on this identical
72
+ * candidate under this same approval, after a failure the plane itself confirmed as infrastructure. Part of the scope
73
+ * digest when present; read strictly. A scope carries terms or this purpose, never both.
74
+ */
75
+ export declare const releaseCheckPurposeSchema: z.ZodObject<{
76
+ version: z.ZodLiteral<1>;
77
+ purpose: z.ZodLiteral<"release-check">;
78
+ repo: z.ZodString;
79
+ packageVersion: z.ZodString;
80
+ branch: z.ZodString;
81
+ pr: z.ZodInt;
82
+ base: z.ZodLiteral<"main">;
83
+ approvedSha: z.ZodString;
84
+ approvedTree: z.ZodString;
85
+ grant: z.ZodObject<{
86
+ id: z.ZodInt;
87
+ digest: z.ZodString;
88
+ }, z.core.$strict>;
89
+ access: z.ZodEnum<{
90
+ sandboxed: "sandboxed";
91
+ "sign-ins": "sign-ins";
92
+ unsandboxed: "unsandboxed";
93
+ }>;
94
+ }, z.core.$strict>;
95
+ export type ReleaseCheckPurpose = z.infer<typeof releaseCheckPurposeSchema>;
96
+ export declare function readReleaseCheckPurpose(input: unknown): ContractResult<ReleaseCheckPurpose>;
97
+ /** The digest a scope binds for its release check purpose, domain-separated from the terms' and every other input. */
98
+ export declare function releaseCheckPurposeDigest(purpose: ReleaseCheckPurpose): string;
99
+ /** Strict stored purpose: null column → null; anything that isn't exactly a purpose, as written → a problem. */
100
+ export declare function storedReleaseCheckPurpose(raw: unknown): {
101
+ ok: true;
102
+ purpose: ReleaseCheckPurpose | null;
103
+ } | {
104
+ ok: false;
105
+ problem: string;
106
+ };
107
+ export declare function serializeReleaseCheckPurpose(purpose: ReleaseCheckPurpose): string;
@@ -72,3 +72,56 @@ export function storedReleaseTerms(raw) {
72
72
  return { ok: false, problem: "the release terms are not stored exactly as written" };
73
73
  return { ok: true, terms: read.value };
74
74
  }
75
+ /**
76
+ * A release check's purpose (docs/plans/pipeline-speed.md, P0): what a person agrees to when they approve a release
77
+ * whose later commits can't carry (its checks run outside the sandbox, or carrying is off) — this exact candidate is a
78
+ * release's check. It never carries a later commit, never lets CI stand in for the check's units and never approves
79
+ * itself; it lets the plane hold the check to the release's strict report rules and rerun it, on this identical
80
+ * candidate under this same approval, after a failure the plane itself confirmed as infrastructure. Part of the scope
81
+ * digest when present; read strictly. A scope carries terms or this purpose, never both.
82
+ */
83
+ export const releaseCheckPurposeSchema = versioned(1, {
84
+ purpose: z.literal("release-check"),
85
+ repo: z.string().min(1).max(4096),
86
+ packageVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
87
+ branch: z.string().min(1).max(255).regex(/^[A-Za-z0-9._/-]+$/),
88
+ pr: z.int().min(1),
89
+ base: z.literal("main"),
90
+ approvedSha: sha,
91
+ approvedTree: sha,
92
+ grant: z.strictObject({ id: z.int(), digest: z.string().min(1).max(128) }),
93
+ access: z.enum(["sandboxed", "sign-ins", "unsandboxed"]),
94
+ });
95
+ export function readReleaseCheckPurpose(input) {
96
+ return readVersioned(releaseCheckPurposeSchema, input);
97
+ }
98
+ /** The digest a scope binds for its release check purpose, domain-separated from the terms' and every other input. */
99
+ export function releaseCheckPurposeDigest(purpose) {
100
+ return createHash("sha256").update(`release-check-purpose:${JSON.stringify(purpose)}`).digest("hex");
101
+ }
102
+ /** Strict stored purpose: null column → null; anything that isn't exactly a purpose, as written → a problem. */
103
+ export function storedReleaseCheckPurpose(raw) {
104
+ if (raw === null || raw === undefined)
105
+ return { ok: true, purpose: null };
106
+ if (typeof raw !== "string")
107
+ return { ok: false, problem: "the release check purpose is not stored as text" };
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(raw);
111
+ }
112
+ catch {
113
+ return { ok: false, problem: "the release check purpose can't be read" };
114
+ }
115
+ const read = readReleaseCheckPurpose(parsed);
116
+ if (!read.ok)
117
+ return { ok: false, problem: `the release check purpose doesn't match its contract (${read.issues[0]?.line ?? "invalid"})` };
118
+ if (JSON.stringify(read.value) !== raw)
119
+ return { ok: false, problem: "the release check purpose is not stored exactly as written" };
120
+ return { ok: true, purpose: read.value };
121
+ }
122
+ export function serializeReleaseCheckPurpose(purpose) {
123
+ const read = readReleaseCheckPurpose(purpose);
124
+ if (!read.ok)
125
+ throw new Error(`The release check purpose doesn't match its contract: ${read.issues[0]?.line ?? "invalid"}`);
126
+ return JSON.stringify(read.value);
127
+ }
@@ -8,7 +8,10 @@ const stop = () => stopping.abort();
8
8
  process.on("SIGTERM", stop);
9
9
  process.on("SIGINT", stop);
10
10
  try {
11
- await superviseController({ file, argv, signal: stopping.signal, onState: state => {
11
+ // A stop is forwarded to the controller (SIGTERM) and waited for. Past 55 s its whole tree is stopped outright: before
12
+ // launchd's own ExitTimeOut (60 s) kills this process and could leave the controller running without it. `up` signs
13
+ // off within 50 s at its default grace and stops itself if this process dies first (operate.ts upCommand).
14
+ await superviseController({ file, argv, signal: stopping.signal, shutdownMs: 55_000, onState: state => {
12
15
  if (state.phase === "backoff")
13
16
  process.stderr.write(`Controller exited (${state.exit?.signal ?? state.exit?.code ?? "unknown"}); restart in ${state.retryMs} ms.\n`);
14
17
  } });
@@ -0,0 +1,41 @@
1
+ /** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
2
+ export declare const FORWARD_ONLY: readonly string[];
3
+ export type RescueWorld = {
4
+ /** The live database. */
5
+ database: string;
6
+ /** The installation's state folder: deployments stage under staged-upgrades/ there. */
7
+ stateDir: string;
8
+ /** Every deployment journal under staged-upgrades/: its stage and contents, null when unreadable. */
9
+ journals(): {
10
+ stage: string;
11
+ journal: Record<string, unknown> | null;
12
+ }[];
13
+ /** The builder run's worktree, from the plane's records (the deploy script's --source), or null. */
14
+ runWorktree(run: number): string | null;
15
+ /** The deployment's own recovery for that stage (release-adapters.ts recoverDeploy). */
16
+ recover(input: {
17
+ run: number;
18
+ worktree: string;
19
+ stage: string;
20
+ }): Promise<{
21
+ ok: true;
22
+ } | {
23
+ ok: false;
24
+ message: string;
25
+ }>;
26
+ };
27
+ export type RescueOutcome = {
28
+ ok: true;
29
+ acted: boolean;
30
+ stage: string | null;
31
+ phase: string | null;
32
+ words: string[];
33
+ } | {
34
+ ok: false;
35
+ reason: "usage" | "conflict" | "damaged" | "forward" | "failed";
36
+ message: string;
37
+ };
38
+ /** What recovery will do from a phase, in a few words. */
39
+ export declare function rescuePlan(phase: string, journal: Record<string, unknown>): string[];
40
+ /** Find the one unfinished deployment (or the named stage), verify its journal, and recover it (with `yes`). */
41
+ export declare function rescueDeployment(stageArg: string | undefined, yes: boolean, world: RescueWorld): Promise<RescueOutcome>;