toolroll 0.9.51 → 0.9.52

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.
@@ -123,6 +123,7 @@ export async function recoverPreparedObserverGap(store, input) {
123
123
  const actionId = store.recordAction({ at: new Date().toISOString(), actor: "system", repo: current.binding.run.repo,
124
124
  taskId: current.binding.run.taskId, runId: receipt.targetRun, action: "process absence recovered",
125
125
  outcome: digest, source: "work" });
126
+ store.compactRunProcesses(new Date(), receipt.targetRun);
126
127
  return { ok: true, recorded: true, ...result, actionId, certificatePath };
127
128
  }));
128
129
  }
package/dist/retention.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { reclaimDatabase } from "./database-reclaim.js";
1
2
  /**
2
3
  * Retention (v105): how long Toolroll keeps run evidence and logs,
3
4
  * finished checkouts' records, chat messages and notifications. Until an
@@ -56,7 +57,7 @@ export const RETENTION_KINDS = [
56
57
  { kind: "evidence", label: "Run evidence and logs", detail: "Diffs, check logs, screenshots and reports saved for each run" },
57
58
  { kind: "checkouts", label: "Finished checkout records", detail: "Records of build checkouts that were let go and are no longer on disk" },
58
59
  { kind: "chat", label: "Chat messages", detail: "Messages in lead and team chats" },
59
- { kind: "notifications", label: "Notifications", detail: "Delivered or dismissed notifications" },
60
+ { kind: "notifications", label: "Notifications", detail: "Resolved notifications" },
60
61
  ];
61
62
  /** The periods the page offers for each kind; the command line takes any number of days from 1 to 3650. */
62
63
  const LONG_CHOICES = [30, 90, 180, 365, 730, null];
@@ -183,12 +184,11 @@ export function retentionPlan(store, evidenceRoot, now, periods = store.retentio
183
184
  items.chat = rows.slice(0, SWEEP_BATCH).map(row => ({ id: Number(row["id"]), bytes: Number(row["bytes"]) }));
184
185
  }
185
186
  if (periods.notifications !== null) {
186
- const rows = db.prepare(`SELECT n.id AS id, length(n.subject) + length(n.body) + COALESCE(length(n.receipt), 0) + COALESCE(length(n.last_error), 0) + 64 AS bytes FROM notification n
187
- WHERE n.created_at < ? AND (n.resolved_at IS NOT NULL OR n.delivered_at IS NOT NULL)
188
- AND NOT (n.claim_owner IS NOT NULL AND n.claim_expires_at > ?)
187
+ const rows = db.prepare(`SELECT n.id AS id, length(n.subject) + length(n.body) + 64 AS bytes FROM notification n
188
+ WHERE n.created_at < ? AND n.resolved_at IS NOT NULL
189
189
  AND NOT EXISTS (SELECT 1 FROM notification_delivery d WHERE d.notification = n.id AND d.claim_owner IS NOT NULL AND d.claim_expires_at > ?)
190
190
  AND NOT (n.resolved_at IS NULL AND n.task_ref IS NOT NULL AND NOT EXISTS (SELECT 1 FROM task_ref r WHERE r.id = n.task_ref AND ${finishedTask("r")}))
191
- ORDER BY n.id LIMIT ?`).all(cutoff(now, periods.notifications), now.toISOString(), now.toISOString(), SWEEP_BATCH + 1);
191
+ ORDER BY n.id LIMIT ?`).all(cutoff(now, periods.notifications), now.toISOString(), SWEEP_BATCH + 1);
192
192
  more.notifications = rows.length > SWEEP_BATCH;
193
193
  items.notifications = rows.slice(0, SWEEP_BATCH).map(row => ({ id: Number(row["id"]), bytes: Number(row["bytes"]) }));
194
194
  }
@@ -297,6 +297,7 @@ export function sweepRetention(store, evidenceRoot, now, actor = "worker") {
297
297
  const counts = plan.counts.map(one => ({ ...one, count: done[one.kind].count, bytes: done[one.kind].bytes }));
298
298
  const freed = counts.reduce((sum, one) => sum + one.bytes, 0);
299
299
  const ledgerId = store.recordAction({ at: now.toISOString(), actor, repo: null, taskId: null, runId: null, action: "retention sweep", outcome: freed > 0 || counts.some(one => one.count > 0) ? "removed" : "nothing due", source: "policy", detail: sweepWords(counts, freed) });
300
+ reclaimDatabase(store.handle, "retention");
300
301
  return { at: now.toISOString(), counts, freed, ledgerId };
301
302
  }
302
303
  /** Whether a day has passed since the last sweep. */
package/dist/serve.js CHANGED
@@ -429,7 +429,7 @@ export function createDecisionServer(options) {
429
429
  /** One-click connections on their way: the service's sign-in page and back, 15 minutes at most. */
430
430
  const connectVisits = new Map();
431
431
  /** Flows open in a browser (v88): who's here, and a nudge when one changes. */
432
- // Live views push on write (live-bus.ts): the revision triggers move, the rooms hear it.
432
+ // Live views push on write (live-bus.ts): each commit moves the workspace revision, the rooms hear it.
433
433
  const liveBus = createLiveBus();
434
434
  const liveFollower = followWorkspace(store, () => workspaceRevision.current(), liveBus, { file: store.databaseFile() });
435
435
  const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow), { bus: liveBus });
@@ -1111,6 +1111,8 @@ export function createSettingsHandlers(runtime) {
1111
1111
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
1112
1112
  return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
1113
1113
  const made = await backupNow(store, databaseFile, "manual", clock);
1114
+ if (made.ok && made.checkpoint !== null && "problem" in made.checkpoint)
1115
+ return redirect(response, `/settings/backups?problem=${encodeURIComponent(`Backed up. The activity log failed its tamper check, so it was not anchored: ${made.checkpoint.problem}`)}`);
1114
1116
  return redirect(response, made.ok ? `/settings/backups?said=${encodeURIComponent("Backed up.")}` : `/settings/backups?problem=${encodeURIComponent(`The backup failed: ${made.error}`)}`);
1115
1117
  }
1116
1118
  // Sprint 8: change the backup schedule. A step-up; the ledger keeps before → after.
@@ -1978,7 +1980,7 @@ export function createSettingsHandlers(runtime) {
1978
1980
  const existing = loadBotToken({}, options.telegramTokenFile);
1979
1981
  const hasEnv = (envValue(process.env, "TELEGRAM_TOKEN") ?? "") !== "";
1980
1982
  const csrf = who.via === "cookie" ? who.session.csrf : "";
1981
- return sendScreen(response, 400, settingsPage(chromeFor(who.via === "cookie" ? who.session.project : defaultProject, "settings"), existing, hasEnv, csrf, saved.message, null, null, null, null, {
1983
+ return sendScreen(response, 400, settingsPage(chromeFor(who.via === "cookie" ? who.session.project : defaultProject, "settings"), existing, hasEnv, csrf, saved.message, options.configDir === undefined ? null : effectivePrimary(process.env, options.configDir, loadBotToken(process.env, options.telegramTokenFile) !== null), null, null, null, {
1982
1984
  ...store.permissionDefault(),
1983
1985
  canManage: who.role === "approver",
1984
1986
  }));
@@ -3138,6 +3138,7 @@ export declare function settingsPage(chrome: Chrome, existing: TokenSource | nul
3138
3138
  channel: string | null;
3139
3139
  implicit: boolean;
3140
3140
  configured: string[];
3141
+ legacyWarning?: string;
3141
3142
  } | null, push?: {
3142
3143
  available: boolean;
3143
3144
  devices: PushSubscription[];
@@ -12313,6 +12313,7 @@ export function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging
12313
12313
  kind: "settings",
12314
12314
  said: problem,
12315
12315
  groups: settingsGroups(messaging, telegramFailing),
12316
+ ...(messaging?.legacyWarning ? { legacyWebhookWarning: messaging.legacyWarning } : {}),
12316
12317
  theme,
12317
12318
  accent: requestContext.getStore()?.accent ?? DEFAULT_ACCENT,
12318
12319
  accentPresets: ACCENT_PRESETS,
@@ -12346,6 +12347,7 @@ export function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging
12346
12347
  };
12347
12348
  return screen("Settings", [
12348
12349
  "<h1>Settings</h1>",
12350
+ messaging?.legacyWarning ? `<p data-legacy-webhooks>Legacy webhooks are deprecated. Connect <a href="/settings/slack" style="display:inline-flex;min-height:44px;align-items:center">Slack</a> or <a href="/settings/discord" style="display:inline-flex;min-height:44px;align-items:center">Discord</a> in Chat settings.</p>` : "",
12349
12351
  settingsTiles(view.groups),
12350
12352
  phone === null ? "" : phoneSetupHtml(phone),
12351
12353
  appearanceCard(csrf),
package/dist/store.d.ts CHANGED
@@ -152,7 +152,7 @@ export type SealedLedgerEntry = LedgerEntry & {
152
152
  hash: string;
153
153
  } | null;
154
154
  };
155
- export declare const SCHEMA_VERSION = 113;
155
+ export declare const SCHEMA_VERSION = 114;
156
156
  /**
157
157
  * The migrations `toolroll update` may carry a database through in place: each
158
158
  * one only adds (a table, a column, an accepted value) and leaves every saved row
@@ -166,6 +166,11 @@ export declare const SCHEMA_VERSION = 113;
166
166
  * api_token columns (project limit, rotation); existing tokens read as before.
167
167
  * v112 only adds the two request-budget tables.
168
168
  * v113 adds MCP sign-in tables and API-token purpose metadata.
169
+ * v114 is the one declared exception to "only adds", and the rehearsal checks
170
+ * it as such: a settled run's exited process witnesses become one summary row
171
+ * (rows removed equal the witnesses summarized), and notification drops its
172
+ * delivery columns only after every value moves into one legacy receipt per row
173
+ * (rows and ids carried whole; legacy receipts added equal the rows carried).
169
174
  */
170
175
  export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
171
176
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
@@ -1030,19 +1035,15 @@ export type Notification = {
1030
1035
  subject: string;
1031
1036
  body: string;
1032
1037
  createdAt: string;
1033
- attempts: number;
1034
- lastAttemptAt: string | null;
1035
- lastError: string | null;
1036
- deliveredAt: string | null;
1037
- receipt: string | null;
1038
1038
  /** The closed push attention class, or null = this fact never pushes. */
1039
1039
  pushClass: "decision" | "pick" | "merge" | "attention" | "progress" | null;
1040
1040
  /** The machine-minted console path a push may deep-link — never free text. */
1041
1041
  link: string | null;
1042
1042
  /**
1043
1043
  * When the fact stopped wanting a person — a decision answered, an incident
1044
- * resolved. Distinct from delivery, and it never deletes the row: receipts
1045
- * are the audit trail of what was actually sent.
1044
+ * resolved. Distinct from delivery, and it never deletes the row: the
1045
+ * per-destination receipts (notification_delivery) are the audit trail of
1046
+ * what was actually sent.
1046
1047
  */
1047
1048
  resolvedAt: string | null;
1048
1049
  scope: "unknown" | "installation" | "project" | "task";
@@ -1051,6 +1052,14 @@ export type Notification = {
1051
1052
  taskId: string | null;
1052
1053
  run: number | null;
1053
1054
  };
1055
+ /** One notification's delivery state at one destination (notification_delivery). */
1056
+ export type NotificationReceipt = Notification & {
1057
+ attempts: number;
1058
+ lastAttemptAt: string | null;
1059
+ lastError: string | null;
1060
+ deliveredAt: string | null;
1061
+ receipt: string | null;
1062
+ };
1054
1063
  export type TelegramDelivery = Notification & {
1055
1064
  destination: string;
1056
1065
  claimGeneration: number;
@@ -1868,6 +1877,9 @@ export declare function openStoreNoMigrate(file: string, options?: OpenOptions):
1868
1877
  * retries. Correction children (the same-session structured-output
1869
1878
  * repairs) are not attempts; they continue one. */
1870
1879
  export declare const REVIEW_ROOT_ATTEMPTS = 3;
1880
+ /** The receipt v114 keeps a notification's old single-destination delivery under. No sender claims it: every real
1881
+ * destination is a kind and a 64-character hash, or a Telegram binding. */
1882
+ export declare const LEGACY_NOTIFICATION_DESTINATION = "legacy:single-destination";
1871
1883
  /** v34: artifact.kind admits 'report'. A table already rebuilt to the v38
1872
1884
  * shape (migrate() runs unconditionally on every open, including a fresh
1873
1885
  * database created straight at the current shape) is a DONE shape too —
@@ -2489,8 +2501,16 @@ export declare class Store {
2489
2501
  fresh?: number;
2490
2502
  }): LedgerChainReport;
2491
2503
  /** A checkpoint of the chain as it stands: its head, recorded here and handed back to be copied off the machine.
2492
- * Never over a chain that doesn't verify: that would vouch for what broke it. */
2493
- ledgerCheckpoint(by: string, now: Date): {
2504
+ * Never over a chain that doesn't verify: that would vouch for what broke it. `automatic` (before a backup, after
2505
+ * a monitoring delivery): at the verified head, or at `head` (one that was sent) once the walk reached it, only past
2506
+ * the newest checkpoint (null otherwise), and with no ledger entry of its own, so a checkpoint never makes new
2507
+ * activity for the next backup or delivery to checkpoint again. */
2508
+ ledgerCheckpoint(by: string, now: Date, automatic?: {
2509
+ head?: {
2510
+ through: number;
2511
+ hash: string;
2512
+ };
2513
+ }): {
2494
2514
  through: number;
2495
2515
  hash: string;
2496
2516
  } | {
@@ -4869,13 +4889,6 @@ export declare class Store {
4869
4889
  notificationById(id: number): Notification | null;
4870
4890
  notificationsAfter(id: number, limit?: number): Notification[];
4871
4891
  listNotifications(only?: "pending" | "all"): Notification[];
4872
- recordDelivery(id: number, outcome: {
4873
- ok: true;
4874
- receipt: string | null;
4875
- } | {
4876
- ok: false;
4877
- error: string;
4878
- }, now: Date): void;
4879
4892
  /**
4880
4893
  * Close a gap's notification episode. Called when its capability verifies:
4881
4894
  * the next failure is a *new* fact, and a dedupe key that suppressed it
@@ -7448,6 +7461,12 @@ export declare class Store {
7448
7461
  * every finished run is visited, a batch at a time. Never signals. */
7449
7462
  recordFinishedRunExits(now: Date, batch?: number): number;
7450
7463
  finishUnspawnedProcess(witness: number, now: Date): void;
7464
+ /** Settled custody, compacted (v114; compactSettledRunProcesses): a finished run whose witnesses all exited keeps
7465
+ * one summary row. A settled stop, an approver's settlement and a recovery compact their run at once; the worker's
7466
+ * reconcile pass (recordFinishedRunExits) compacts every other settled run, including one that settled at run end. */
7467
+ compactRunProcesses(now: Date, only?: number): number;
7468
+ /** Whether a run's witnesses were compacted into its summary: they existed, and all exited. */
7469
+ processesSummarized(runId: number): boolean;
7451
7470
  /** The reconcile road for a pid-less witness a crash or a thrown spawn
7452
7471
  * left behind: once its run has finished, no live child is tracked for it
7453
7472
  * here, every other witness of the run has exited and none of the run's
@@ -7739,24 +7758,8 @@ export declare class Store {
7739
7758
  error: string;
7740
7759
  retryAt?: string;
7741
7760
  }, now: Date): boolean;
7742
- /** Read receipts without changing the meaning of shell/webhook history. */
7743
- telegramDeliveries(binding: TelegramBinding): Notification[];
7744
- /**
7745
- * Claim pending notifications for one deliverer. Two deliverers — the
7746
- * bridge and `outbox deliver` — select-then-send-then-record, and without
7747
- * a claim both can send the same row in the gap. The claim is a short
7748
- * lease on the act of sending; a deliverer that dies mid-send leaves rows
7749
- * that unclaim themselves by expiry.
7750
- */
7751
- claimDeliveries(owner: string, ttlMs: number, now: Date, only?: "all" | "urgent"): Notification[];
7752
- /** Finalize only what this owner still holds. A lapsed claim writes nothing. */
7753
- finalizeDelivery(id: number, owner: string, outcome: {
7754
- ok: true;
7755
- receipt: string | null;
7756
- } | {
7757
- ok: false;
7758
- error: string;
7759
- }, now: Date): boolean;
7761
+ /** The notifications this pairing holds, each with its delivery state there. */
7762
+ telegramDeliveries(binding: TelegramBinding): NotificationReceipt[];
7760
7763
  /** The digest cadence: null everyMs = off. */
7761
7764
  telegramDigest(): TelegramDigest;
7762
7765
  /** Set (or clear, with null) the cadence. Turning it on starts the
@@ -7879,14 +7882,6 @@ export declare class Store {
7879
7882
  /** The message now showing this card, and what it shows. */
7880
7883
  setChatCardMessage(id: number, message: string, digest: string): void;
7881
7884
  markTelegramDigestSent(now: Date): void;
7882
- /** Every batched row finalized delivered AND the anchor moved, in ONE
7883
- * transaction (v4 review, finding 5): a digest is either recorded whole
7884
- * or not at all. Rows whose claim lapsed meanwhile are reported, not
7885
- * silently skipped. */
7886
- finalizeDigest(ids: readonly number[], owner: string, receipt: string | null, now: Date): {
7887
- finalized: number;
7888
- lapsed: number;
7889
- };
7890
7885
  /** Routine rows waiting for the next digest: pending, and not urgent.
7891
7886
  * A row the live pairing delivered, or skipped as pre-pairing history,
7892
7887
  * is settled and not waiting. */