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
package/dist/store.d.ts CHANGED
@@ -26,7 +26,7 @@ import type { Scope } from "./scope.js";
26
26
  import type { QualityMode } from "./quality.js";
27
27
  import type { ProgressSnapshot } from "./plan.js";
28
28
  import { type CheckProgressSnapshot } from "./check-progress.js";
29
- import { type ReleaseTerms } from "./contracts/release-terms.js";
29
+ import { type ReleaseCheckPurpose, type ReleaseTerms } from "./contracts/release-terms.js";
30
30
  import { type ProjectAccess } from "./project-access.js";
31
31
  import { type LedgerEntry } from "./action-ledger.js";
32
32
  import type { LimitReading, LimitWindow } from "./provider-limits.js";
@@ -106,6 +106,10 @@ export declare const LIVE_STEER_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_pause
106
106
  /** v136: a run's own "Show commands in the live view" answer, captured once before its live file opens (live-detail.ts).
107
107
  * Absent, unreadable or anything but 1 means off. */
108
108
  export declare const RUN_LIVE_DETAIL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_live_detail (\n run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,\n commands INTEGER NOT NULL CHECK (commands IN (0,1)),\n captured_at TEXT NOT NULL\n);\n-- The Agents view's Live grid (docs/plans/agents-view.md \u00A72.5): every role's running runs and its recently ended ones.\n-- (id > 0 keeps them to the grid's own query, which names it: no other query's plan is offered them.)\nCREATE INDEX IF NOT EXISTS run_live_open ON run (id) WHERE outcome IS NULL AND id > 0;\nCREATE INDEX IF NOT EXISTS run_live_recent ON run (finished_at) WHERE outcome IS NOT NULL AND id > 0;\n";
109
+ /** v137 (docs/plans/actors-lessons.md): explicit lifecycle, latency timelines and the continuous database copy. New
110
+ * tables only. A lifecycle row's `holder` binds it to one registration (a worker's registered_at) or one console
111
+ * process; a row whose holder no longer matches says nothing. Timelines are bounded and pruned as they are written. */
112
+ export declare const V137_SCHEMA = "\nCREATE TABLE IF NOT EXISTS service_lifecycle (\n component TEXT PRIMARY KEY,\n state TEXT NOT NULL CHECK (state IN ('started','draining','stopped')),\n holder TEXT NOT NULL,\n pid INTEGER,\n changed_at TEXT NOT NULL,\n beat_at TEXT NOT NULL,\n drain_until TEXT\n);\nCREATE TABLE IF NOT EXISTS latency_timeline (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n kind TEXT NOT NULL CHECK (kind IN ('console','release-check','deploy')),\n subject TEXT NOT NULL,\n task_id TEXT,\n run_id INTEGER,\n started_at TEXT NOT NULL,\n total_ms INTEGER NOT NULL,\n milestones_json TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS latency_timeline_by_kind ON latency_timeline (kind, id);\nCREATE INDEX IF NOT EXISTS latency_timeline_by_run ON latency_timeline (run_id) WHERE run_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS latency_timeline_by_task ON latency_timeline (task_id) WHERE task_id IS NOT NULL;\nCREATE TABLE IF NOT EXISTS live_backup (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n enabled INTEGER NOT NULL DEFAULT 1,\n every_minutes INTEGER NOT NULL DEFAULT 5,\n keep INTEGER NOT NULL DEFAULT 24,\n folder TEXT,\n holder TEXT,\n held_until TEXT,\n latest_file TEXT,\n latest_sha256 TEXT,\n latest_at TEXT,\n latest_schema INTEGER,\n latest_bytes INTEGER,\n tried_at TEXT,\n problem TEXT\n);\n";
109
113
  /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
110
114
  export declare const LIVE_DETAIL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS live_detail_setting (\n repo TEXT PRIMARY KEY,\n commands INTEGER NOT NULL CHECK (commands IN (0,1)),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n-- Every run a steering note reached, kept as it happens (live-notes.ts): a\n-- note's own attached/mailed fields move to the next attempt, this does not.\nCREATE TABLE IF NOT EXISTS steer_note_run (\n note INTEGER NOT NULL REFERENCES task_steer(id) ON DELETE CASCADE,\n run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,\n how TEXT NOT NULL CHECK (how IN ('attached','mailed','read')),\n at TEXT NOT NULL,\n PRIMARY KEY (note, run, how)\n);\nCREATE INDEX IF NOT EXISTS steer_note_run_by_run ON steer_note_run (run, note);\n";
111
115
  export declare const CHAT_APPROVAL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_approval_setting (\n approver TEXT NOT NULL,\n scope TEXT NOT NULL,\n enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),\n full_access INTEGER NOT NULL DEFAULT 0 CHECK (full_access IN (0,1)),\n attempt_cap_microusd INTEGER CHECK (attempt_cap_microusd IS NULL OR attempt_cap_microusd >= 0),\n generation INTEGER NOT NULL,\n digest TEXT NOT NULL,\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (approver, scope)\n);\n";
@@ -186,7 +190,7 @@ export type SealedLedgerEntry = LedgerEntry & {
186
190
  hash: string;
187
191
  } | null;
188
192
  };
189
- export declare const SCHEMA_VERSION = 136;
193
+ export declare const SCHEMA_VERSION = 138;
190
194
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
191
195
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
192
196
  /**
@@ -331,6 +335,12 @@ export declare const V135_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
331
335
  definition: string;
332
336
  saved: string;
333
337
  }>>>>;
338
+ /** v138 (docs/plans/pipeline-speed.md, P0): a scope's release check purpose, part of its digest when present. Saved
339
+ * rows read NULL. */
340
+ export declare const V138_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
341
+ definition: string;
342
+ saved: string;
343
+ }>>>>;
334
344
  /**
335
345
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
336
346
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -2339,6 +2349,8 @@ export declare class Store {
2339
2349
  proposedVia?: ScopeAuthor | null;
2340
2350
  /** v135: release terms (release-once.md); undefined keeps the ones the scope carries, null removes them. */
2341
2351
  releaseTerms?: ReleaseTerms | null;
2352
+ /** v138: a release check's purpose; undefined keeps the scope's, null removes it. Never beside terms. */
2353
+ releaseCheck?: ReleaseCheckPurpose | null;
2342
2354
  }): void;
2343
2355
  /**
2344
2356
  * Re-file a scope under its OWN current words (v47): the route is
@@ -6600,6 +6612,9 @@ export declare class Store {
6600
6612
  };
6601
6613
  heartbeatWatchLease(runner: string, repo: string, owner: string, ttlMs: number, now: Date): boolean;
6602
6614
  releaseWatchLease(runner: string, repo: string, owner: string, now: Date): void;
6615
+ /** Lapse every watch lease a runner holds now: its holder was proved gone (runner.ts registerRunnerIfIdle). The rows
6616
+ * stay, so the next acquire takes them over and recovers the superseded incarnation as after any crash. */
6617
+ expireWatchLeasesOf(runner: string, now: Date): number;
6603
6618
  /** The first still-unexpired watch lease this runner holds, if any (arc 2:
6604
6619
  * the takeover door refuses while one exists). */
6605
6620
  liveWatchLeaseOf(runner: string, now: Date): {
@@ -7790,3 +7805,111 @@ export declare function predecessorWatches(store: Store, runner: string, now: Da
7790
7805
  repo: string;
7791
7806
  owner: string;
7792
7807
  }[];
7808
+ /** The longest a watch lease is taken or renewed for (durable-actors' MAX_HOST_LEASE_DURATION_MS). */
7809
+ export declare const MAX_WATCH_LEASE_MS = 60000;
7810
+ /** A service component's lifecycle (durable-actors' host lifecycle reports): started, then heartbeats, then draining
7811
+ * (no new admission; its own drain ends by drain_until), then stopped (retired). */
7812
+ export type LifecycleState = "started" | "draining" | "stopped";
7813
+ export type LifecycleRow = {
7814
+ component: string;
7815
+ state: LifecycleState;
7816
+ holder: string;
7817
+ pid: number | null;
7818
+ changedAt: string;
7819
+ beatAt: string;
7820
+ drainUntil: string | null;
7821
+ };
7822
+ /** One component's lifecycle row, or null (never reported: a component from before v137 says nothing). */
7823
+ export declare function lifecycleOf(store: Store, component: string): LifecycleRow | null;
7824
+ /** Every reported component, by name. */
7825
+ export declare function lifecycles(store: Store): LifecycleRow[];
7826
+ /**
7827
+ * Report a component's state. `started` replaces whatever an earlier holder left; `draining` and `stopped` only move the
7828
+ * current holder's row (a stale process can't mark its successor draining), and never back from stopped. Returns
7829
+ * whether the row now says what was asked.
7830
+ */
7831
+ export declare function reportLifecycle(store: Store, component: string, state: LifecycleState, holder: string, now: Date, extra?: {
7832
+ pid?: number | null;
7833
+ drainUntil?: Date | null;
7834
+ }): boolean;
7835
+ /** The short heartbeat: the current holder's row, while it is not stopped. False means another holder took it. */
7836
+ export declare function beatLifecycle(store: Store, component: string, holder: string, now: Date): boolean;
7837
+ /** A milestone in a latency timeline: what happened, how long after the operation began (ms), and an optional short
7838
+ * reason. Never a secret: route ids, phase and part names, fixed reason words. */
7839
+ export type Milestone = {
7840
+ name: string;
7841
+ ms: number;
7842
+ detail?: string;
7843
+ };
7844
+ export type LatencyKind = "console" | "release-check" | "deploy";
7845
+ export type LatencyTimeline = {
7846
+ id: number;
7847
+ kind: LatencyKind;
7848
+ subject: string;
7849
+ taskId: string | null;
7850
+ runId: number | null;
7851
+ startedAt: string;
7852
+ totalMs: number;
7853
+ milestones: Milestone[];
7854
+ };
7855
+ /** At most this many milestones per timeline (the rest are dropped, the last kept), and timelines kept per kind. */
7856
+ export declare const MAX_MILESTONES = 40;
7857
+ export declare const TIMELINES_KEPT = 200;
7858
+ /** Timelines older than this go whatever their count. */
7859
+ export declare const TIMELINE_DAYS = 14;
7860
+ /** Milestones made safe to keep: bounded in number and length, offsets whole non-negative milliseconds, in order. */
7861
+ export declare function boundedMilestones(milestones: readonly Milestone[]): Milestone[];
7862
+ /** Keep one timeline, then prune its kind to the newest TIMELINES_KEPT within TIMELINE_DAYS. */
7863
+ export declare function recordLatencyTimeline(store: Store, entry: {
7864
+ kind: LatencyKind;
7865
+ subject: string;
7866
+ taskId?: string | null;
7867
+ runId?: number | null;
7868
+ startedAt: Date;
7869
+ totalMs: number;
7870
+ milestones: readonly Milestone[];
7871
+ }, now: Date): number;
7872
+ /** Timelines, newest first: of one kind, one run's, or one task's. A row that doesn't read is left out. */
7873
+ export declare function latencyTimelines(store: Store, filter: {
7874
+ kind?: LatencyKind;
7875
+ runId?: number;
7876
+ taskId?: string;
7877
+ limit?: number;
7878
+ }): LatencyTimeline[];
7879
+ /** The continuous copy (live-backup.ts): how often, how many, where (null: `backups/live` beside the database). */
7880
+ export type LiveBackupSettings = {
7881
+ enabled: boolean;
7882
+ everyMinutes: number;
7883
+ keep: number;
7884
+ folder: string | null;
7885
+ };
7886
+ export declare const DEFAULT_LIVE_BACKUP: LiveBackupSettings;
7887
+ /** The latest copy that passed its integrity check, as it was when it was made. */
7888
+ export type LiveBackupCopy = {
7889
+ file: string;
7890
+ sha256: string;
7891
+ madeAt: string;
7892
+ schemaVersion: number;
7893
+ bytes: number;
7894
+ };
7895
+ export type LiveBackupState = {
7896
+ settings: LiveBackupSettings;
7897
+ latest: LiveBackupCopy | null;
7898
+ triedAt: string | null;
7899
+ problem: string | null;
7900
+ };
7901
+ export declare function liveBackupState(store: Store): LiveBackupState;
7902
+ /** Change the continuous copy's settings; the ledger keeps before → after. A new folder forgets the latest copy (it is
7903
+ * in the old one). */
7904
+ export declare function setLiveBackupSettings(store: Store, next: LiveBackupSettings, by: string, now: Date): LiveBackupSettings;
7905
+ /** Take (or keep) the continuous copy's lease until `until`: one process copies at a time. */
7906
+ export declare function holdLiveBackup(store: Store, holder: string, now: Date, until: Date): boolean;
7907
+ export declare function releaseLiveBackup(store: Store, holder: string): void;
7908
+ /** A copy's outcome: a verified copy becomes the latest; a failure is kept as the problem (the latest stays). */
7909
+ export declare function recordLiveBackup(store: Store, outcome: {
7910
+ ok: true;
7911
+ copy: LiveBackupCopy;
7912
+ } | {
7913
+ ok: false;
7914
+ problem: string;
7915
+ }, now: Date): void;
package/dist/store.js CHANGED
@@ -85,7 +85,7 @@ import { authenticate as runnerAuthenticate } from "./runner.js";
85
85
  import { stampNewProjectLevel } from "./check-levels.js";
86
86
  import { parseCheckProgressSnapshot } from "./check-progress.js";
87
87
  import { authorityStandsSql } from "./approval-authority.js";
88
- import { releasePolicyDigest, storedReleaseTerms, readReleaseTerms, readReleasePolicy } from "./contracts/release-terms.js";
88
+ import { releasePolicyDigest, serializeReleaseCheckPurpose, storedReleaseCheckPurpose, storedReleaseTerms, readReleaseTerms, readReleasePolicy } from "./contracts/release-terms.js";
89
89
  import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
90
90
  import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
91
91
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
@@ -459,6 +459,49 @@ CREATE TABLE IF NOT EXISTS run_live_detail (
459
459
  CREATE INDEX IF NOT EXISTS run_live_open ON run (id) WHERE outcome IS NULL AND id > 0;
460
460
  CREATE INDEX IF NOT EXISTS run_live_recent ON run (finished_at) WHERE outcome IS NOT NULL AND id > 0;
461
461
  `;
462
+ /** v137 (docs/plans/actors-lessons.md): explicit lifecycle, latency timelines and the continuous database copy. New
463
+ * tables only. A lifecycle row's `holder` binds it to one registration (a worker's registered_at) or one console
464
+ * process; a row whose holder no longer matches says nothing. Timelines are bounded and pruned as they are written. */
465
+ export const V137_SCHEMA = `
466
+ CREATE TABLE IF NOT EXISTS service_lifecycle (
467
+ component TEXT PRIMARY KEY,
468
+ state TEXT NOT NULL CHECK (state IN ('started','draining','stopped')),
469
+ holder TEXT NOT NULL,
470
+ pid INTEGER,
471
+ changed_at TEXT NOT NULL,
472
+ beat_at TEXT NOT NULL,
473
+ drain_until TEXT
474
+ );
475
+ CREATE TABLE IF NOT EXISTS latency_timeline (
476
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
477
+ kind TEXT NOT NULL CHECK (kind IN ('console','release-check','deploy')),
478
+ subject TEXT NOT NULL,
479
+ task_id TEXT,
480
+ run_id INTEGER,
481
+ started_at TEXT NOT NULL,
482
+ total_ms INTEGER NOT NULL,
483
+ milestones_json TEXT NOT NULL
484
+ );
485
+ CREATE INDEX IF NOT EXISTS latency_timeline_by_kind ON latency_timeline (kind, id);
486
+ CREATE INDEX IF NOT EXISTS latency_timeline_by_run ON latency_timeline (run_id) WHERE run_id IS NOT NULL;
487
+ CREATE INDEX IF NOT EXISTS latency_timeline_by_task ON latency_timeline (task_id) WHERE task_id IS NOT NULL;
488
+ CREATE TABLE IF NOT EXISTS live_backup (
489
+ id INTEGER PRIMARY KEY CHECK (id = 1),
490
+ enabled INTEGER NOT NULL DEFAULT 1,
491
+ every_minutes INTEGER NOT NULL DEFAULT 5,
492
+ keep INTEGER NOT NULL DEFAULT 24,
493
+ folder TEXT,
494
+ holder TEXT,
495
+ held_until TEXT,
496
+ latest_file TEXT,
497
+ latest_sha256 TEXT,
498
+ latest_at TEXT,
499
+ latest_schema INTEGER,
500
+ latest_bytes INTEGER,
501
+ tried_at TEXT,
502
+ problem TEXT
503
+ );
504
+ `;
462
505
  /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
463
506
  export const LIVE_DETAIL_SCHEMA = `
464
507
  CREATE TABLE IF NOT EXISTS live_detail_setting (
@@ -756,7 +799,14 @@ const REGISTERED_AT_FILING = "again, as it was filed";
756
799
  // v136 adds, and changes no saved row: each run's own "Show commands in the live view" answer, captured once before its
757
800
  // live file opens (run_live_detail; docs/plans/agents-view.md §2.4). Saved runs have none, which every reader takes as
758
801
  // commands off: permission is never recovered from a transcript or the project's current setting.
759
- export const SCHEMA_VERSION = 136;
802
+ // v137 adds, and changes no saved row (docs/plans/actors-lessons.md): each service component's lifecycle
803
+ // (service_lifecycle: started, draining or stopped, with a short heartbeat), bounded per-operation latency timelines
804
+ // (latency_timeline: slow console requests, release check parts, deploy phases) and the continuous database copy's
805
+ // settings and latest verified copy (live_backup, one row; absent reads every 5 minutes, keep 24, beside the database).
806
+ // v138 adds, and changes no saved row: a release check's purpose (task_scope.release_check_json; docs/plans/
807
+ // pipeline-speed.md, P0) — this exact candidate is a release's check, for a release that can't carry. Saved rows read
808
+ // NULL: no purpose (a termsless release task stays exactly as it was approved).
809
+ export const SCHEMA_VERSION = 138;
760
810
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
761
811
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
762
812
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -889,7 +939,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
889
939
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
890
940
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
891
941
  */
892
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136]);
942
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138]);
893
943
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
894
944
  export const V119_ADDED_COLUMNS = Object.freeze({
895
945
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1010,6 +1060,11 @@ export const V134_ADDED_COLUMNS = Object.freeze({
1010
1060
  export const V135_ADDED_COLUMNS = Object.freeze({
1011
1061
  task_scope: { release_terms_json: { definition: "TEXT", saved: "NULL" } },
1012
1062
  });
1063
+ /** v138 (docs/plans/pipeline-speed.md, P0): a scope's release check purpose, part of its digest when present. Saved
1064
+ * rows read NULL. */
1065
+ export const V138_ADDED_COLUMNS = Object.freeze({
1066
+ task_scope: { release_check_json: { definition: "TEXT", saved: "NULL" } },
1067
+ });
1013
1068
  /**
1014
1069
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1015
1070
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -4240,6 +4295,13 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4240
4295
  }
4241
4296
  // v136: each run's own live-view answer (a new table only; saved runs have none: commands off).
4242
4297
  db.exec(RUN_LIVE_DETAIL_SCHEMA);
4298
+ // v137: lifecycle, latency timelines and the continuous copy (new tables only).
4299
+ db.exec(V137_SCHEMA);
4300
+ // v138: a scope's release check purpose (V138_ADDED_COLUMNS: saved rows NULL, no purpose).
4301
+ for (const [table, columns] of Object.entries(V138_ADDED_COLUMNS)) {
4302
+ for (const [column, { definition }] of Object.entries(columns))
4303
+ addColumn(db, table, column, definition);
4304
+ }
4243
4305
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4244
4306
  // nothing to reclaim.
4245
4307
  if (!fresh)
@@ -6669,7 +6731,7 @@ export class Store {
6669
6731
  saveScope(scope, mutation = {}, options = {}) {
6670
6732
  this.once(mutation, "saveScope", () => this.transact(() => {
6671
6733
  // The text's current author mark, read before this write (approval policy §1.4).
6672
- const prior = this.db.prepare("SELECT proposed_via, release_terms_json FROM task_scope WHERE task_id = ?").get(scope.taskId);
6734
+ const prior = this.db.prepare("SELECT proposed_via, release_terms_json, release_check_json FROM task_scope WHERE task_id = ?").get(scope.taskId);
6673
6735
  // THE filing invariant (foundations findings 5/13/19): every scope
6674
6736
  // row leaves this method either RESOLVED (working profile stamped,
6675
6737
  // digest recomputed to bind it, version 2) or UNRESOLVED with its
@@ -6833,6 +6895,12 @@ export class Store {
6833
6895
  // v135: release terms the caller names, else the ones this scope already carries (a refile keeps them bound).
6834
6896
  const priorTerms = storedReleaseTerms(prior?.["release_terms_json"]);
6835
6897
  const releaseTerms = options.releaseTerms !== undefined ? options.releaseTerms : priorTerms.ok ? priorTerms.terms : null;
6898
+ // v138: the release check purpose likewise; terms named now replace a kept purpose (a scope carries one or the other).
6899
+ const priorPurpose = storedReleaseCheckPurpose(prior?.["release_check_json"]);
6900
+ const releaseCheck = options.releaseCheck !== undefined ? options.releaseCheck
6901
+ : options.releaseTerms != null ? null : priorPurpose.ok ? priorPurpose.purpose : null;
6902
+ if (releaseTerms !== null && releaseCheck !== null)
6903
+ throw new Error("A scope carries release terms or a release check purpose, never both.");
6836
6904
  const digestInput = {
6837
6905
  goal: scope.goal,
6838
6906
  outOfScope: scope.outOfScope,
@@ -6842,6 +6910,7 @@ export class Store {
6842
6910
  qualityMode,
6843
6911
  candidate: scope.candidate ?? null,
6844
6912
  releaseTerms,
6913
+ releaseCheck,
6845
6914
  };
6846
6915
  // EVERY route filed since v47 binds into the digest — routine-shaped
6847
6916
  // ones included — so approval freezes exactly which agent runs each
@@ -6871,8 +6940,8 @@ export class Store {
6871
6940
  .prepare(`INSERT INTO task_scope
6872
6941
  (task_id, goal, out_of_scope, touches, budget_microusd, proposed_at, digest, approved_at, approved_by, approved_digest,
6873
6942
  profile_json, profile_state, unresolved_reason, digest_version, profile_provenance, proposed_chain_json, acceptance_json, quality_mode,
6874
- risk_level, proposed_route_json, route_era, candidate, release_terms_json)
6875
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6943
+ risk_level, proposed_route_json, route_era, candidate, release_terms_json, release_check_json)
6944
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6876
6945
  ON CONFLICT (task_id) DO UPDATE SET
6877
6946
  goal = excluded.goal, out_of_scope = excluded.out_of_scope,
6878
6947
  touches = excluded.touches, budget_microusd = excluded.budget_microusd,
@@ -6884,7 +6953,7 @@ export class Store {
6884
6953
  profile_provenance = excluded.profile_provenance, proposed_chain_json = excluded.proposed_chain_json,
6885
6954
  acceptance_json = excluded.acceptance_json, quality_mode = excluded.quality_mode,
6886
6955
  risk_level = excluded.risk_level, proposed_route_json = excluded.proposed_route_json, route_era = excluded.route_era,
6887
- candidate = excluded.candidate, release_terms_json = excluded.release_terms_json`)
6956
+ candidate = excluded.candidate, release_terms_json = excluded.release_terms_json, release_check_json = excluded.release_check_json`)
6888
6957
  .run(scope.taskId, scope.goal, scope.outOfScope, JSON.stringify(scope.touches), scope.budgetMicrousd ?? null, scope.proposedAt, boundDigest, scope.approvedAt, scope.approvedBy, scope.approvedDigest, profile === null ? null : canonicalProfileJson(profile), profile === null ? "unresolved" : "resolved", unresolvedReason, profile === null ? 1 : 2, provenance,
6889
6958
  // Fallback chains (v30-v114) are gone: no filing names one.
6890
6959
  null, scope.acceptance.length === 0 ? null : JSON.stringify(scope.acceptance), qualityMode,
@@ -6892,7 +6961,7 @@ export class Store {
6892
6961
  // routine risk, the value its signed terms always carried.
6893
6962
  "routine", route === null ? null : canonicalRouteJson(route),
6894
6963
  // The durable era: every row this method writes is a routed row.
6895
- ROUTE_ERA, scope.candidate ?? null, releaseTerms === null ? null : serializeReleaseTerms(releaseTerms));
6964
+ ROUTE_ERA, scope.candidate ?? null, releaseTerms === null ? null : serializeReleaseTerms(releaseTerms), releaseCheck === null ? null : serializeReleaseCheckPurpose(releaseCheck));
6896
6965
  // Who wrote THIS text (mate arc, ruling 2): set per write, so a human
6897
6966
  // rewrite clears the lead's mark and a mate rewrite sets it — but only a
6898
6967
  // person's write clears one (approval policy §1.4): the lead (its token)
@@ -7017,7 +7086,14 @@ export class Store {
7017
7086
  // A DEPLOY GATE FROM CHAT (chat-management.md §0a.5): a signed candidate or release terms seal from chat only
7018
7087
  // with the release card's evidence (its act and card digest), never through the plain plan card or a generic
7019
7088
  // one. Read from the raw row, so a terms column that doesn't parse is a deploy gate too.
7020
- const deployGate = (typeof working["candidate"] === "string" && working["candidate"] !== "") || working["release_terms_json"] != null;
7089
+ const deployGate = (typeof working["candidate"] === "string" && working["candidate"] !== "") || working["release_terms_json"] != null || working["release_check_json"] != null;
7090
+ // A RELEASE CHECK PURPOSE (v138) seals, like terms, only on a person's own yes: the ceremony or a paired-chat tap
7091
+ // through the release card — never a mode, a grant, a carry or an automatic seal (those need terms).
7092
+ const purpose = storedReleaseCheckPurpose(working["release_check_json"]);
7093
+ if (!purpose.ok)
7094
+ return false;
7095
+ if (purpose.purpose !== null && basis !== undefined && basis.kind !== "chat")
7096
+ return false;
7021
7097
  if (deployGate && basis?.kind === "chat" && !(basis.card === "release" && typeof basis.cardDigest === "string" && /^[0-9a-f]{64}$/.test(basis.cardDigest)))
7022
7098
  return false;
7023
7099
  // AUTOMATIC RELEASE SEALS (auto-approve-green.md): only on a release task (a candidate and release terms signed
@@ -11652,7 +11728,7 @@ export class Store {
11652
11728
  if (route === null) {
11653
11729
  return { ok: false, reason: "unreadable", detail: scope.approvedRouteJson == null ? "the approval sealed no agent route — re-file the scope and approve it again" : "the approval's sealed agent route cannot be read — re-file the scope and approve it again" };
11654
11730
  }
11655
- const fields = { goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null };
11731
+ const fields = { goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null, releaseCheck: scope.releaseCheck ?? null };
11656
11732
  const chain = scope.approvalKind === "chain" ? chainFromJson(scope.approvedChainJson ?? null) : null;
11657
11733
  const target = chain !== null ? { chain } : (scope.approvedProfile ?? null);
11658
11734
  if (target === null)
@@ -17508,7 +17584,8 @@ export class Store {
17508
17584
  acquireWatchLease(runner, repo, owner, ttlMs, now) {
17509
17585
  return this.transact(() => {
17510
17586
  const stamp = now.toISOString();
17511
- const expires = new Date(now.getTime() + ttlMs).toISOString();
17587
+ // Never longer than MAX_WATCH_LEASE_MS, whatever a caller asks: a crashed holder's project is free within a minute.
17588
+ const expires = new Date(now.getTime() + Math.min(ttlMs, MAX_WATCH_LEASE_MS)).toISOString();
17512
17589
  const row = this.db
17513
17590
  .prepare("SELECT * FROM watch_lease WHERE runner = ? AND repo = ?")
17514
17591
  .get(runner, repo);
@@ -17545,7 +17622,7 @@ export class Store {
17545
17622
  const { changes } = this.db
17546
17623
  .prepare(`UPDATE watch_lease SET expires_at = ?, heartbeat_at = ?
17547
17624
  WHERE runner = ? AND repo = ? AND owner = ? AND expires_at > ?`)
17548
- .run(new Date(now.getTime() + ttlMs).toISOString(), now.toISOString(), runner, repo, owner, now.toISOString());
17625
+ .run(new Date(now.getTime() + Math.min(ttlMs, MAX_WATCH_LEASE_MS)).toISOString(), now.toISOString(), runner, repo, owner, now.toISOString());
17549
17626
  return Number(changes) > 0;
17550
17627
  }
17551
17628
  releaseWatchLease(runner, repo, owner, now) {
@@ -17553,6 +17630,12 @@ export class Store {
17553
17630
  .prepare("UPDATE watch_lease SET expires_at = ? WHERE runner = ? AND repo = ? AND owner = ?")
17554
17631
  .run(now.toISOString(), runner, repo, owner);
17555
17632
  }
17633
+ /** Lapse every watch lease a runner holds now: its holder was proved gone (runner.ts registerRunnerIfIdle). The rows
17634
+ * stay, so the next acquire takes them over and recovers the superseded incarnation as after any crash. */
17635
+ expireWatchLeasesOf(runner, now) {
17636
+ const stamp = now.toISOString();
17637
+ return Number(this.db.prepare("UPDATE watch_lease SET expires_at = ? WHERE runner = ? AND expires_at > ?").run(stamp, runner, stamp).changes);
17638
+ }
17556
17639
  /** The first still-unexpired watch lease this runner holds, if any (arc 2:
17557
17640
  * the takeover door refuses while one exists). */
17558
17641
  liveWatchLeaseOf(runner, now) {
@@ -20189,6 +20272,14 @@ export function scopeTermsProblem(row) {
20189
20272
  if (!terms.ok)
20190
20273
  return terms.problem;
20191
20274
  }
20275
+ // v138: likewise the release check purpose, and never beside terms.
20276
+ if ("release_check_json" in row) {
20277
+ const purpose = storedReleaseCheckPurpose(row["release_check_json"]);
20278
+ if (!purpose.ok)
20279
+ return purpose.problem;
20280
+ if (purpose.purpose !== null && row["release_terms_json"] != null)
20281
+ return "the scope carries both release terms and a release check purpose";
20282
+ }
20192
20283
  for (const column of ["approved_at", "approved_by", "approved_digest", "profile_json", "approved_profile_json", "proposed_chain_json", "approved_chain_json", "proposed_route_json", "approved_route_json", "unresolved_reason"]) {
20193
20284
  if (row[column] !== null && row[column] !== undefined && typeof row[column] !== "string")
20194
20285
  return `${column.replace(/_/g, " ")} is not stored as text`;
@@ -20234,6 +20325,7 @@ function readScope(row) {
20234
20325
  routeEra: row["route_era"] === null || row["route_era"] === undefined ? null : Number(row["route_era"]),
20235
20326
  candidate: typeof row["candidate"] === "string" && row["candidate"] !== "" ? row["candidate"] : null,
20236
20327
  releaseTerms: (() => { const read = storedReleaseTerms(row["release_terms_json"]); return read.ok ? read.terms : null; })(),
20328
+ releaseCheck: (() => { const read = storedReleaseCheckPurpose(row["release_check_json"]); return read.ok ? read.purpose : null; })(),
20237
20329
  proposedVia: row["proposed_via"] === "mate" || row["proposed_via"] === "coordinator" || row["proposed_via"] === "scout" || row["proposed_via"] === "lead" ? row["proposed_via"] : null,
20238
20330
  };
20239
20331
  }
@@ -20882,3 +20974,149 @@ export function predecessorWatches(store, runner, now) {
20882
20974
  AND (c.released_at IS NULL OR EXISTS (SELECT 1 FROM run r WHERE r.lease_id = c.lease_id AND r.outcome IS NULL))))
20883
20975
  ORDER BY w.repo`).all(runner, now.toISOString()).map(row => ({ repo: String(row["repo"]), owner: String(row["owner"]) }));
20884
20976
  }
20977
+ // ---- v137: lifecycle, latency timelines, the continuous copy (docs/plans/actors-lessons.md) ----------------------
20978
+ /** The longest a watch lease is taken or renewed for (durable-actors' MAX_HOST_LEASE_DURATION_MS). */
20979
+ export const MAX_WATCH_LEASE_MS = 60_000;
20980
+ function lifecycleRowOf(row) {
20981
+ return { component: String(row["component"]), state: String(row["state"]), holder: String(row["holder"]), pid: row["pid"] == null ? null : Number(row["pid"]),
20982
+ changedAt: String(row["changed_at"]), beatAt: String(row["beat_at"]), drainUntil: row["drain_until"] == null ? null : String(row["drain_until"]) };
20983
+ }
20984
+ /** One component's lifecycle row, or null (never reported: a component from before v137 says nothing). */
20985
+ export function lifecycleOf(store, component) {
20986
+ const row = store.handle.prepare("SELECT * FROM service_lifecycle WHERE component = ?").get(component);
20987
+ return row === undefined ? null : lifecycleRowOf(row);
20988
+ }
20989
+ /** Every reported component, by name. */
20990
+ export function lifecycles(store) {
20991
+ return store.handle.prepare("SELECT * FROM service_lifecycle ORDER BY component").all().map(lifecycleRowOf);
20992
+ }
20993
+ /**
20994
+ * Report a component's state. `started` replaces whatever an earlier holder left; `draining` and `stopped` only move the
20995
+ * current holder's row (a stale process can't mark its successor draining), and never back from stopped. Returns
20996
+ * whether the row now says what was asked.
20997
+ */
20998
+ export function reportLifecycle(store, component, state, holder, now, extra = {}) {
20999
+ const stamp = now.toISOString();
21000
+ const until = extra.drainUntil == null ? null : extra.drainUntil.toISOString();
21001
+ if (state === "started") {
21002
+ store.handle.prepare(`INSERT INTO service_lifecycle (component, state, holder, pid, changed_at, beat_at, drain_until) VALUES (?, 'started', ?, ?, ?, ?, NULL)
21003
+ ON CONFLICT(component) DO UPDATE SET state = 'started', holder = excluded.holder, pid = excluded.pid, changed_at = excluded.changed_at, beat_at = excluded.beat_at, drain_until = NULL`)
21004
+ .run(component, holder, extra.pid ?? null, stamp, stamp);
21005
+ return true;
21006
+ }
21007
+ const changed = store.handle.prepare(`UPDATE service_lifecycle SET state = ?, changed_at = ?, beat_at = ?, drain_until = CASE WHEN ? = 'draining' THEN ? ELSE drain_until END
21008
+ WHERE component = ? AND holder = ? AND state <> 'stopped'`).run(state, stamp, stamp, state, until, component, holder).changes;
21009
+ return Number(changed) === 1;
21010
+ }
21011
+ /** The short heartbeat: the current holder's row, while it is not stopped. False means another holder took it. */
21012
+ export function beatLifecycle(store, component, holder, now) {
21013
+ return Number(store.handle.prepare("UPDATE service_lifecycle SET beat_at = ? WHERE component = ? AND holder = ? AND state <> 'stopped'")
21014
+ .run(now.toISOString(), component, holder).changes) === 1;
21015
+ }
21016
+ /** At most this many milestones per timeline (the rest are dropped, the last kept), and timelines kept per kind. */
21017
+ export const MAX_MILESTONES = 40;
21018
+ export const TIMELINES_KEPT = 200;
21019
+ /** Timelines older than this go whatever their count. */
21020
+ export const TIMELINE_DAYS = 14;
21021
+ const plainText = (value, max) => value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, max);
21022
+ /** Milestones made safe to keep: bounded in number and length, offsets whole non-negative milliseconds, in order. */
21023
+ export function boundedMilestones(milestones) {
21024
+ const clean = milestones.filter(one => typeof one.name === "string" && Number.isFinite(one.ms)).map(one => ({
21025
+ name: plainText(one.name, 80), ms: Math.max(0, Math.round(one.ms)),
21026
+ ...(typeof one.detail === "string" && one.detail.trim() !== "" ? { detail: plainText(one.detail, 160) } : {}),
21027
+ })).filter(one => one.name !== "");
21028
+ return clean.length <= MAX_MILESTONES ? clean : [...clean.slice(0, MAX_MILESTONES - 1), clean[clean.length - 1]];
21029
+ }
21030
+ /** Keep one timeline, then prune its kind to the newest TIMELINES_KEPT within TIMELINE_DAYS. */
21031
+ export function recordLatencyTimeline(store, entry, now) {
21032
+ return store.transact(() => {
21033
+ const id = Number(store.handle.prepare(`INSERT INTO latency_timeline (kind, subject, task_id, run_id, started_at, total_ms, milestones_json) VALUES (?, ?, ?, ?, ?, ?, ?)`)
21034
+ .run(entry.kind, plainText(entry.subject, 120), entry.taskId ?? null, entry.runId ?? null, entry.startedAt.toISOString(), Math.max(0, Math.round(entry.totalMs)), JSON.stringify(boundedMilestones(entry.milestones))).lastInsertRowid);
21035
+ const cutoff = new Date(now.getTime() - TIMELINE_DAYS * 86_400_000).toISOString();
21036
+ store.handle.prepare(`DELETE FROM latency_timeline WHERE kind = ? AND (started_at < ? OR id <= (SELECT id FROM latency_timeline WHERE kind = ? ORDER BY id DESC LIMIT 1 OFFSET ?))`)
21037
+ .run(entry.kind, cutoff, entry.kind, TIMELINES_KEPT);
21038
+ return id;
21039
+ });
21040
+ }
21041
+ /** Timelines, newest first: of one kind, one run's, or one task's. A row that doesn't read is left out. */
21042
+ export function latencyTimelines(store, filter) {
21043
+ const where = [], args = [];
21044
+ if (filter.kind !== undefined) {
21045
+ where.push("kind = ?");
21046
+ args.push(filter.kind);
21047
+ }
21048
+ if (filter.runId !== undefined && filter.taskId !== undefined) {
21049
+ where.push("(run_id = ? OR task_id = ?)");
21050
+ args.push(filter.runId, filter.taskId);
21051
+ }
21052
+ else if (filter.runId !== undefined) {
21053
+ where.push("run_id = ?");
21054
+ args.push(filter.runId);
21055
+ }
21056
+ else if (filter.taskId !== undefined) {
21057
+ where.push("task_id = ?");
21058
+ args.push(filter.taskId);
21059
+ }
21060
+ const rows = store.handle.prepare(`SELECT * FROM latency_timeline${where.length === 0 ? "" : ` WHERE ${where.join(" AND ")}`} ORDER BY id DESC LIMIT ?`).all(...args, Math.min(200, Math.max(1, filter.limit ?? 20)));
21061
+ return rows.flatMap(row => {
21062
+ let milestones;
21063
+ try {
21064
+ const parsed = JSON.parse(String(row["milestones_json"]));
21065
+ milestones = Array.isArray(parsed) ? boundedMilestones(parsed) : [];
21066
+ }
21067
+ catch {
21068
+ return [];
21069
+ }
21070
+ return [{ id: Number(row["id"]), kind: String(row["kind"]), subject: String(row["subject"]), taskId: row["task_id"] == null ? null : String(row["task_id"]),
21071
+ runId: row["run_id"] == null ? null : Number(row["run_id"]), startedAt: String(row["started_at"]), totalMs: Number(row["total_ms"]), milestones }];
21072
+ });
21073
+ }
21074
+ export const DEFAULT_LIVE_BACKUP = { enabled: true, everyMinutes: 5, keep: 24, folder: null };
21075
+ export function liveBackupState(store) {
21076
+ const row = store.handle.prepare("SELECT * FROM live_backup WHERE id = 1").get();
21077
+ if (row === undefined)
21078
+ return { settings: { ...DEFAULT_LIVE_BACKUP }, latest: null, triedAt: null, problem: null };
21079
+ const latest = row["latest_file"] == null || row["latest_sha256"] == null || row["latest_at"] == null || row["latest_schema"] == null ? null
21080
+ : { file: String(row["latest_file"]), sha256: String(row["latest_sha256"]), madeAt: String(row["latest_at"]), schemaVersion: Number(row["latest_schema"]), bytes: Number(row["latest_bytes"] ?? 0) };
21081
+ return { settings: { enabled: Number(row["enabled"]) === 1, everyMinutes: Number(row["every_minutes"]), keep: Number(row["keep"]), folder: row["folder"] == null ? null : String(row["folder"]) },
21082
+ latest, triedAt: row["tried_at"] == null ? null : String(row["tried_at"]), problem: row["problem"] == null ? null : String(row["problem"]) };
21083
+ }
21084
+ const liveBackupWords = (one) => `${one.enabled ? `every ${one.everyMinutes} min` : "off"}, keep ${one.keep}, ${one.folder ?? "the default folder"}`;
21085
+ /** Change the continuous copy's settings; the ledger keeps before → after. A new folder forgets the latest copy (it is
21086
+ * in the old one). */
21087
+ export function setLiveBackupSettings(store, next, by, now) {
21088
+ return store.transact(() => {
21089
+ const before = liveBackupState(store).settings;
21090
+ store.handle.prepare(`INSERT INTO live_backup (id, enabled, every_minutes, keep, folder) VALUES (1, ?, ?, ?, ?)
21091
+ ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, every_minutes = excluded.every_minutes, keep = excluded.keep, folder = excluded.folder`)
21092
+ .run(next.enabled ? 1 : 0, next.everyMinutes, next.keep, next.folder);
21093
+ if (before.folder !== next.folder)
21094
+ store.handle.prepare("UPDATE live_backup SET latest_file = NULL, latest_sha256 = NULL, latest_at = NULL, latest_schema = NULL, latest_bytes = NULL WHERE id = 1").run();
21095
+ if (liveBackupWords(before) !== liveBackupWords(next)) {
21096
+ store.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: "continuous backup settings changed", outcome: "changed", source: "policy", detail: `${liveBackupWords(before)} → ${liveBackupWords(next)}` });
21097
+ }
21098
+ return liveBackupState(store).settings;
21099
+ });
21100
+ }
21101
+ /** Take (or keep) the continuous copy's lease until `until`: one process copies at a time. */
21102
+ export function holdLiveBackup(store, holder, now, until) {
21103
+ return store.transact(() => {
21104
+ store.handle.prepare("INSERT OR IGNORE INTO live_backup (id) VALUES (1)").run();
21105
+ return Number(store.handle.prepare("UPDATE live_backup SET holder = ?, held_until = ? WHERE id = 1 AND (holder IS NULL OR holder = ? OR held_until IS NULL OR held_until < ?)")
21106
+ .run(holder, until.toISOString(), holder, now.toISOString()).changes) === 1;
21107
+ });
21108
+ }
21109
+ export function releaseLiveBackup(store, holder) {
21110
+ store.handle.prepare("UPDATE live_backup SET holder = NULL, held_until = NULL WHERE id = 1 AND holder = ?").run(holder);
21111
+ }
21112
+ /** A copy's outcome: a verified copy becomes the latest; a failure is kept as the problem (the latest stays). */
21113
+ export function recordLiveBackup(store, outcome, now) {
21114
+ store.handle.prepare("INSERT OR IGNORE INTO live_backup (id) VALUES (1)").run();
21115
+ if (outcome.ok) {
21116
+ store.handle.prepare("UPDATE live_backup SET latest_file = ?, latest_sha256 = ?, latest_at = ?, latest_schema = ?, latest_bytes = ?, tried_at = ?, problem = NULL WHERE id = 1")
21117
+ .run(outcome.copy.file, outcome.copy.sha256, outcome.copy.madeAt, outcome.copy.schemaVersion, outcome.copy.bytes, now.toISOString());
21118
+ }
21119
+ else {
21120
+ store.handle.prepare("UPDATE live_backup SET tried_at = ?, problem = ? WHERE id = 1").run(now.toISOString(), plainText(outcome.problem, 300));
21121
+ }
21122
+ }
package/dist/surface.js CHANGED
@@ -110,7 +110,9 @@ const GUIDE_ROWS = [
110
110
  { name: "limit", takesValue: true, meaning: "<step>=<minutes>: one waiting step's time limit" },
111
111
  { name: "tap", takesValue: true, meaning: "the Homebrew tap, owner/name" },
112
112
  { name: "release-gate", takesValue: true, meaning: "<id>: show why an update pause left before a deployment's swap is safe to lift" },
113
- { name: "yes", takesValue: false, meaning: "with --release-gate: lift that one pause once it is proved" }],
113
+ { name: "yes", takesValue: false, meaning: "with --release-gate: lift that one pause once it is proved; with --recover-deploy: recover" },
114
+ { name: "recover-deploy", takesValue: false, meaning: "put back a deployment that stopped half way: stop only its own proved leftovers, restart the previous service, lift the pause" },
115
+ { name: "stage", takesValue: true, meaning: "with --recover-deploy: the deployment's staging folder" }],
114
116
  notableReasons: ["usage", "ambiguous", "drift", "timeout", "check-failed", "ci-failed", "tree-mismatch", "conflict", "mixed-runtime", "gate-held", "after-swap"] },
115
117
  operator("demo", "a seeded throwaway sandbox"),
116
118
  ...KNOWLEDGE_DESCRIPTORS.map(spec => ({ invocation: `knowledge ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text or source file for impact" }] } : {}) })),
@@ -364,7 +364,7 @@ function includeIn(path, gitFile = "git") {
364
364
  const text = regularFile(path);
365
365
  if (text === null)
366
366
  return "an unreadable file";
367
- const parsed = spawnSync(gitFile, ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
367
+ const parsed = spawnSync(gitFile, ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, killSignal: "SIGKILL", env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
368
368
  if (parsed.status !== 0)
369
369
  return "an unreadable file";
370
370
  const named = parsed.stdout.split("\0").find(name => /^(include\.path|includeif\..*\.path)$/i.test(name));
@@ -488,7 +488,7 @@ function gitView(checkout, kind, binding = {}, gitFile = "git") {
488
488
  throw new Error("the checkout's git config couldn't be read as a plain file");
489
489
  const configPath = join(view, "config");
490
490
  writeFileSync(configPath, config, { mode: 0o600 });
491
- const plain = { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } };
491
+ const plain = { cwd: "/", encoding: "utf8", timeout: 5_000, killSignal: "SIGKILL", env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } };
492
492
  if (/worktreeconfig/i.test(config)) {
493
493
  const flag = spawnSync(gitFile, ["config", "--file", configPath, "--no-includes", "--type=bool", "--get", "extensions.worktreeConfig"], plain);
494
494
  if (flag.status !== 0 && flag.status !== 1)
@@ -1038,7 +1038,7 @@ export function lfsExtensionIn(checkout, gitFile = "git") {
1038
1038
  catch {
1039
1039
  return false;
1040
1040
  }
1041
- const parsed = spawnSync(gitFile, ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
1041
+ const parsed = spawnSync(gitFile, ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, killSignal: "SIGKILL", env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
1042
1042
  return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(name) || ["include", "includeif"].includes(keyParts(name).section));
1043
1043
  }
1044
1044
  /** Every command runs against a view of the checkout's repository (gitView) whose config and info are copies the
@@ -1148,7 +1148,7 @@ export function viewedGitSync(cwd, args, options = {}) {
1148
1148
  const view = gitView(cwd, "read", options.binding, gitFile);
1149
1149
  try {
1150
1150
  const env = { PATH: process.env["PATH"] ?? "", HOME: process.env["HOME"] ?? "", LANG: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", ...view.env };
1151
- const bounded = { cwd, env, encoding: "utf8", timeout: options.timeoutMs ?? 60_000, maxBuffer: options.maxBuffer ?? 1_048_576, stdio: ["ignore", "pipe", "pipe"] };
1151
+ const bounded = { cwd, env, encoding: "utf8", timeout: options.timeoutMs ?? 60_000, killSignal: "SIGKILL", maxBuffer: options.maxBuffer ?? 1_048_576, stdio: ["ignore", "pipe", "pipe"] };
1152
1152
  const own = spawnSync(gitFile, ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], bounded);
1153
1153
  if (own.status !== 0)
1154
1154
  throw new Error("its config couldn't be read");
@@ -1234,7 +1234,7 @@ export function lazyFetchSupported(gitFile = "git") {
1234
1234
  const executable = gitExecutablePath(gitFile);
1235
1235
  let known = lazyFetchByGit.get(executable);
1236
1236
  if (known === undefined) {
1237
- const probe = spawnSync(executable, ["--no-lazy-fetch", "version"], { cwd: "/", encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"],
1237
+ const probe = spawnSync(executable, ["--no-lazy-fetch", "version"], { cwd: "/", encoding: "utf8", timeout: 5_000, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "pipe"],
1238
1238
  env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
1239
1239
  known = probe.status === 0;
1240
1240
  lazyFetchByGit.set(executable, known);