@gmickel/gno 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +69 -6
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +98 -1
  4. package/assets/skill/cli-reference.md +140 -0
  5. package/assets/skill/examples.md +45 -0
  6. package/assets/skill/mcp-reference.md +62 -0
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +358 -24
  17. package/spec/compiled-context.md +68 -0
  18. package/spec/mcp.md +223 -2
  19. package/spec/output-schemas/capture-receipt.schema.json +3 -0
  20. package/spec/output-schemas/compiled-context-check.schema.json +44 -0
  21. package/spec/output-schemas/compiled-context-file.schema.json +165 -0
  22. package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
  23. package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
  24. package/spec/output-schemas/memory-remember.schema.json +8 -2
  25. package/spec/output-schemas/request-status.schema.json +113 -0
  26. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  27. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  28. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  29. package/spec/output-schemas/sessions-status.schema.json +432 -0
  30. package/src/app/compiled-context-files.ts +361 -0
  31. package/src/app/compiled-context.ts +240 -0
  32. package/src/app/context-surface.ts +7 -2
  33. package/src/cli/commands/ask.ts +14 -2
  34. package/src/cli/commands/audit.ts +4 -0
  35. package/src/cli/commands/capture.ts +55 -96
  36. package/src/cli/commands/context-compiled.ts +136 -0
  37. package/src/cli/commands/daemon.ts +41 -0
  38. package/src/cli/commands/ls.ts +3 -0
  39. package/src/cli/commands/memory.ts +12 -3
  40. package/src/cli/commands/request-status.ts +59 -0
  41. package/src/cli/commands/reset.ts +39 -5
  42. package/src/cli/commands/sessions.ts +713 -0
  43. package/src/cli/commands/shared.ts +14 -1
  44. package/src/cli/errors.ts +10 -3
  45. package/src/cli/program.ts +458 -1
  46. package/src/cli/session-binding.ts +49 -0
  47. package/src/config/types.ts +8 -0
  48. package/src/core/capture-publish.ts +239 -0
  49. package/src/core/capture-sync.ts +3 -0
  50. package/src/core/compiled-context.ts +254 -0
  51. package/src/core/context-budget.ts +2 -10
  52. package/src/core/file-lock.ts +22 -5
  53. package/src/core/folder-setup-planning.ts +2 -1
  54. package/src/core/memory-remember.ts +233 -122
  55. package/src/core/memory-types.ts +11 -0
  56. package/src/core/network-boundary-inventory.ts +16 -0
  57. package/src/core/request-receipts.ts +671 -0
  58. package/src/core/setup-receipt.ts +27 -20
  59. package/src/core/typed-metadata.ts +4 -0
  60. package/src/core/validation.ts +9 -2
  61. package/src/core/windows-private-path.ts +96 -0
  62. package/src/index.ts +11 -2
  63. package/src/ingestion/compiled-context.ts +15 -0
  64. package/src/ingestion/sync.ts +40 -8
  65. package/src/ingestion/walker.ts +5 -4
  66. package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
  67. package/src/mcp/context.ts +8 -0
  68. package/src/mcp/http-egress.ts +15 -3
  69. package/src/mcp/http-transport.ts +2 -0
  70. package/src/mcp/retrieval-warnings.ts +24 -0
  71. package/src/mcp/tools/ask.ts +14 -1
  72. package/src/mcp/tools/capture.ts +87 -83
  73. package/src/mcp/tools/context.ts +46 -2
  74. package/src/mcp/tools/index.ts +119 -7
  75. package/src/mcp/tools/memory-remember.ts +7 -0
  76. package/src/mcp/tools/memory-shared.ts +7 -1
  77. package/src/mcp/tools/query.ts +3 -1
  78. package/src/mcp/tools/request-status.ts +73 -0
  79. package/src/mcp/tools/search.ts +3 -1
  80. package/src/mcp/tools/sessions.ts +208 -0
  81. package/src/mcp/tools/vsearch.ts +3 -1
  82. package/src/sdk/client.ts +252 -89
  83. package/src/sdk/index.ts +13 -0
  84. package/src/sdk/types.ts +82 -3
  85. package/src/serve/capture-service.ts +98 -32
  86. package/src/serve/compiled-context.ts +84 -0
  87. package/src/serve/config-sync.ts +3 -2
  88. package/src/serve/public/app.tsx +15 -1
  89. package/src/serve/public/components/CaptureModal.tsx +26 -8
  90. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  91. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  92. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  93. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  94. package/src/serve/public/components/sessions/api.ts +40 -0
  95. package/src/serve/public/components/sessions/snippet.tsx +53 -0
  96. package/src/serve/public/globals.built.css +1 -1
  97. package/src/serve/public/hooks/use-api.ts +10 -2
  98. package/src/serve/public/lib/request-intent.ts +69 -0
  99. package/src/serve/public/lib/workspace-actions.ts +12 -1
  100. package/src/serve/public/lib/workspace-tabs.ts +4 -0
  101. package/src/serve/public/pages/CompiledContext.tsx +364 -0
  102. package/src/serve/public/pages/Dashboard.tsx +17 -0
  103. package/src/serve/public/pages/DocView.tsx +15 -1
  104. package/src/serve/public/pages/DocumentEditor.tsx +139 -96
  105. package/src/serve/public/pages/Sessions.tsx +350 -0
  106. package/src/serve/resident-runtime.ts +43 -3
  107. package/src/serve/routes/api.ts +476 -147
  108. package/src/serve/routes/sessions.ts +766 -0
  109. package/src/serve/security.ts +9 -0
  110. package/src/serve/server.ts +248 -3
  111. package/src/serve/session-automation.ts +146 -0
  112. package/src/serve/spa-production-build.ts +6 -5
  113. package/src/sessions/archive.ts +348 -0
  114. package/src/sessions/automation-state.ts +444 -0
  115. package/src/sessions/automation-status.ts +239 -0
  116. package/src/sessions/automation.ts +1169 -0
  117. package/src/sessions/binding.ts +105 -0
  118. package/src/sessions/claude-hook.ts +240 -0
  119. package/src/sessions/config.ts +176 -0
  120. package/src/sessions/format.ts +191 -0
  121. package/src/sessions/import-child-env.ts +8 -0
  122. package/src/sessions/import-child.ts +152 -0
  123. package/src/sessions/parsers/claude-code.ts +259 -0
  124. package/src/sessions/parsers/codex.ts +303 -0
  125. package/src/sessions/parsers/hermes.ts +248 -0
  126. package/src/sessions/parsers/openclaw.ts +496 -0
  127. package/src/sessions/parsers/shared.ts +184 -0
  128. package/src/sessions/sanitize.ts +222 -0
  129. package/src/sessions/service.ts +1533 -0
  130. package/src/sessions/setup.ts +477 -0
  131. package/src/sessions/sources.ts +518 -0
  132. package/src/sessions/state.ts +118 -0
  133. package/src/sessions/types.ts +457 -0
  134. package/src/store/sqlite/adapter.ts +68 -16
  135. package/src/store/sqlite/scoped-index.ts +9 -0
  136. package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.zip.sha256 +0 -1
@@ -0,0 +1,671 @@
1
+ /**
2
+ * Durable request receipts for opted-in mutation retries.
3
+ *
4
+ * One private SQLite ledger per index identity (next to the index database,
5
+ * never inside it) records each caller-supplied request ID within its trusted
6
+ * namespace. Admission, recovery and completion run under the existing shared
7
+ * write lease, so the lease plus the unique (namespace, request ID) key is the
8
+ * whole concurrency story: there is no second lock hierarchy.
9
+ *
10
+ * @module src/core/request-receipts
11
+ */
12
+
13
+ import { Database } from "bun:sqlite";
14
+ // node:fs/promises chmod/mkdir: filesystem structure ops, no Bun equivalent
15
+ import { chmod, mkdir } from "node:fs/promises";
16
+ // node:path has no Bun path utilities
17
+ import { basename, dirname, join } from "node:path";
18
+
19
+ import { MCP_ERRORS } from "./errors";
20
+ import { withWriteLock } from "./file-lock";
21
+ import { windowsPrivatePath } from "./windows-private-path";
22
+ import { writeLeasePath } from "./write-lease";
23
+
24
+ /** Committed receipts keep their full outcome this long, then become tombstones. */
25
+ export const REQUEST_RECEIPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
26
+ /** Hard cap on ledger rows (full receipts plus tombstones). */
27
+ export const REQUEST_LEDGER_MAX_ROWS = 100_000;
28
+ export const REQUEST_ID_MAX_LENGTH = 128;
29
+ /** Namespace of the single local owner (CLI, SDK, stdio MCP, REST). */
30
+ export const LOCAL_OWNER_NAMESPACE = "local";
31
+
32
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u;
33
+ /** Ledger directory beside the index databases; survives `gno reset`. */
34
+ export const REQUEST_LEDGER_DIR = "write-receipts";
35
+ const LEDGER_BUSY_TIMEOUT_MS = 5_000;
36
+
37
+ export type RequestOperation = "capture" | "remember" | "document.update";
38
+
39
+ export type RequestReceiptErrorCode =
40
+ | "REQUEST_ID_INVALID"
41
+ | "REQUEST_ID_CONFLICT"
42
+ | "REQUEST_PENDING"
43
+ | "REQUEST_RECOVERY_CONFLICT"
44
+ | "REQUEST_EXPIRED"
45
+ | "REQUEST_CAPACITY_EXHAUSTED"
46
+ | "REQUEST_LEDGER_UNAVAILABLE";
47
+
48
+ export const REQUEST_RECEIPT_HTTP_STATUS: Readonly<
49
+ Record<RequestReceiptErrorCode, number>
50
+ > = {
51
+ REQUEST_ID_INVALID: 400,
52
+ REQUEST_ID_CONFLICT: 409,
53
+ REQUEST_PENDING: 409,
54
+ REQUEST_RECOVERY_CONFLICT: 409,
55
+ REQUEST_EXPIRED: 410,
56
+ REQUEST_CAPACITY_EXHAUSTED: 507,
57
+ REQUEST_LEDGER_UNAVAILABLE: 503,
58
+ };
59
+
60
+ export class RequestReceiptError extends Error {
61
+ readonly code: RequestReceiptErrorCode;
62
+
63
+ constructor(code: RequestReceiptErrorCode, message: string) {
64
+ super(message);
65
+ this.name = "RequestReceiptError";
66
+ this.code = code;
67
+ }
68
+ }
69
+
70
+ /** Non-content pointer to what a committed request produced. */
71
+ export interface RequestResultRef {
72
+ uri?: string;
73
+ docid?: string;
74
+ contentHash?: string;
75
+ sourceHash?: string;
76
+ }
77
+
78
+ /** Attached to a write result when the caller supplied a request ID. */
79
+ export interface RequestReceiptInfo {
80
+ requestId: string;
81
+ status: "committed";
82
+ replayed: boolean;
83
+ committedAt: string;
84
+ }
85
+
86
+ export interface RequestStatusResult {
87
+ requestId: string;
88
+ status: "pending" | "committed" | "expired" | "not_found";
89
+ operation?: RequestOperation;
90
+ createdAt?: string;
91
+ updatedAt?: string;
92
+ result?: RequestResultRef;
93
+ }
94
+
95
+ /** Where a request ledger lives for one index database. */
96
+ export function requestLedgerPath(dbPath: string): string {
97
+ return join(dirname(dbPath), REQUEST_LEDGER_DIR, basename(dbPath));
98
+ }
99
+
100
+ /**
101
+ * Ledger, lease and namespace for the local owner of one index database:
102
+ * the ledger and the write lease derive from the same path.
103
+ */
104
+ export function localRequestLedger(dbPath: string): {
105
+ ledgerPath: string;
106
+ lockPath: string;
107
+ namespace: string;
108
+ } {
109
+ return {
110
+ ledgerPath: requestLedgerPath(dbPath),
111
+ lockPath: writeLeasePath(dbPath),
112
+ namespace: LOCAL_OWNER_NAMESPACE,
113
+ };
114
+ }
115
+
116
+ /** One text line for a write result carrying a request receipt. */
117
+ export function formatRequestReceiptLine(request: RequestReceiptInfo): string {
118
+ return `Request: ${request.requestId} committed${request.replayed ? " (replayed, nothing written again)" : ""}`;
119
+ }
120
+
121
+ const REQUEST_STATUS_NEXT_STEP: Record<RequestStatusResult["status"], string> =
122
+ {
123
+ committed: "Committed: do not resend; the retained outcome replays.",
124
+ pending:
125
+ "Pending: retry the same write with the same request ID to finish it.",
126
+ expired:
127
+ "Expired: this ID already ran and will not run again; check current state before using a new ID.",
128
+ not_found: "Not found: nothing was accepted under this ID.",
129
+ };
130
+
131
+ /** Human-readable request status (CLI and MCP text output). */
132
+ export function formatRequestStatus(result: RequestStatusResult): string {
133
+ const lines = [`Request: ${result.requestId}`, `Status: ${result.status}`];
134
+ if (result.operation) lines.push(`Operation: ${result.operation}`);
135
+ if (result.updatedAt) lines.push(`Updated: ${result.updatedAt}`);
136
+ if (result.result?.uri) lines.push(`URI: ${result.result.uri}`);
137
+ lines.push(REQUEST_STATUS_NEXT_STEP[result.status]);
138
+ return lines.join("\n");
139
+ }
140
+
141
+ /** Validate an opaque caller request ID before anything is admitted. */
142
+ export function validateRequestId(raw: unknown): string {
143
+ if (
144
+ typeof raw !== "string" ||
145
+ raw.length === 0 ||
146
+ raw.length > REQUEST_ID_MAX_LENGTH ||
147
+ !REQUEST_ID_PATTERN.test(raw)
148
+ ) {
149
+ throw new RequestReceiptError(
150
+ "REQUEST_ID_INVALID",
151
+ `requestId must be 1-${REQUEST_ID_MAX_LENGTH} characters of letters, digits, '.', '_', ':' or '-', starting with a letter or digit.`
152
+ );
153
+ }
154
+ return raw;
155
+ }
156
+
157
+ function sha256(value: string): string {
158
+ return new Bun.CryptoHasher("sha256").update(value).digest("hex");
159
+ }
160
+
161
+ /** HTTP MCP namespace: the authorized identity (loopback or bearer digest). */
162
+ export function httpMcpRequestNamespace(securityIdentity: string): string {
163
+ return `mcp-http:${sha256(securityIdentity).slice(0, 32)}`;
164
+ }
165
+
166
+ function canonicalJson(value: unknown): string {
167
+ if (value === null || typeof value !== "object") {
168
+ return JSON.stringify(value) ?? "null";
169
+ }
170
+ if (Array.isArray(value)) {
171
+ return `[${value.map((item) => canonicalJson(item ?? null)).join(",")}]`;
172
+ }
173
+ const entries = Object.entries(value)
174
+ .filter(([, item]) => item !== undefined)
175
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
176
+ return `{${entries
177
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
178
+ .join(",")}}`;
179
+ }
180
+
181
+ /** Digest of the semantic operation; transport identity must not be included. */
182
+ export function requestDigest(
183
+ operation: RequestOperation,
184
+ payload: unknown
185
+ ): string {
186
+ return sha256(`${operation}\u0000${canonicalJson(payload)}`);
187
+ }
188
+
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+ // Ledger
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+
193
+ interface LedgerRow {
194
+ operation: RequestOperation;
195
+ digest: string;
196
+ status: "pending" | "committed" | "expired";
197
+ created_at_ms: number;
198
+ updated_at_ms: number;
199
+ plan_json: string | null;
200
+ published: number;
201
+ result_json: string | null;
202
+ ref_json: string | null;
203
+ }
204
+
205
+ const SCHEMA = `
206
+ CREATE TABLE IF NOT EXISTS request_receipts (
207
+ namespace TEXT NOT NULL,
208
+ request_id TEXT NOT NULL,
209
+ operation TEXT NOT NULL,
210
+ digest TEXT NOT NULL,
211
+ status TEXT NOT NULL CHECK (status IN ('pending', 'committed', 'expired')),
212
+ created_at_ms INTEGER NOT NULL,
213
+ updated_at_ms INTEGER NOT NULL,
214
+ plan_json TEXT,
215
+ published INTEGER NOT NULL DEFAULT 0,
216
+ result_json TEXT,
217
+ ref_json TEXT,
218
+ PRIMARY KEY (namespace, request_id)
219
+ ) WITHOUT ROWID`;
220
+
221
+ /** Ledger directories whose owner-only Windows DACL this process verified. */
222
+ const privateLedgerDirs = new Set<string>();
223
+
224
+ /**
225
+ * Windows ignores POSIX modes: give a new ledger directory the current-user
226
+ * DACL before SQLite creates the database or its WAL/SHM (they inherit it),
227
+ * and refuse an existing one that grants another principal access.
228
+ */
229
+ async function secureLedgerDir(
230
+ dir: string,
231
+ created: string | undefined
232
+ ): Promise<void> {
233
+ if (process.platform !== "win32" || privateLedgerDirs.has(dir)) return;
234
+ await windowsPrivatePath(dir, created !== undefined);
235
+ privateLedgerDirs.add(dir);
236
+ }
237
+
238
+ async function openLedger(path: string): Promise<Database> {
239
+ try {
240
+ const dir = dirname(path);
241
+ const created = await mkdir(dir, { recursive: true, mode: 0o700 });
242
+ await secureLedgerDir(dir, created);
243
+ const db = new Database(path, { create: true, strict: true });
244
+ try {
245
+ // POSIX: private before any journal file exists (they inherit this mode).
246
+ await chmod(path, 0o600);
247
+ db.run(`PRAGMA busy_timeout = ${LEDGER_BUSY_TIMEOUT_MS}`);
248
+ db.run("PRAGMA journal_mode = WAL");
249
+ db.run(SCHEMA);
250
+ } catch (error) {
251
+ db.close();
252
+ throw error;
253
+ }
254
+ return db;
255
+ } catch (error) {
256
+ throw new RequestReceiptError(
257
+ "REQUEST_LEDGER_UNAVAILABLE",
258
+ `Request ledger is unavailable; nothing was written: ${error instanceof Error ? error.message : String(error)}`
259
+ );
260
+ }
261
+ }
262
+
263
+ /** A committed receipt past retention reads as expired even before compaction. */
264
+ function getRow(
265
+ db: Database,
266
+ namespace: string,
267
+ requestId: string,
268
+ nowMs: number
269
+ ): LedgerRow | null {
270
+ const row = readRow(db, namespace, requestId);
271
+ if (
272
+ row?.status !== "committed" ||
273
+ row.updated_at_ms >= nowMs - REQUEST_RECEIPT_RETENTION_MS
274
+ ) {
275
+ return row;
276
+ }
277
+ return { ...row, status: "expired", result_json: null, ref_json: null };
278
+ }
279
+
280
+ function readRow(
281
+ db: Database,
282
+ namespace: string,
283
+ requestId: string
284
+ ): LedgerRow | null {
285
+ return db
286
+ .query<LedgerRow, [string, string]>(
287
+ `SELECT operation, digest, status, created_at_ms, updated_at_ms,
288
+ plan_json, published, result_json, ref_json
289
+ FROM request_receipts WHERE namespace = ? AND request_id = ?`
290
+ )
291
+ .get(namespace, requestId);
292
+ }
293
+
294
+ /** Compact expired receipts, enforce the cap, then admit one row. */
295
+ function admitRow(
296
+ db: Database,
297
+ input: {
298
+ namespace: string;
299
+ requestId: string;
300
+ operation: RequestOperation;
301
+ digest: string;
302
+ nowMs: number;
303
+ status: "pending" | "committed";
304
+ planJson: string | null;
305
+ resultJson: string | null;
306
+ refJson: string | null;
307
+ }
308
+ ): void {
309
+ // Committed on its own: a capacity rejection must not undo compaction.
310
+ db.query(
311
+ `UPDATE request_receipts
312
+ SET status = 'expired', plan_json = NULL, result_json = NULL, ref_json = NULL
313
+ WHERE status = 'committed' AND updated_at_ms < ?`
314
+ ).run(input.nowMs - REQUEST_RECEIPT_RETENTION_MS);
315
+ db.transaction(() => {
316
+ const count =
317
+ db
318
+ .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM request_receipts")
319
+ .get()?.n ?? 0;
320
+ if (count >= REQUEST_LEDGER_MAX_ROWS) {
321
+ throw new RequestReceiptError(
322
+ "REQUEST_CAPACITY_EXHAUSTED",
323
+ `Request ledger is full (${REQUEST_LEDGER_MAX_ROWS} receipts); nothing was written. Retry without a request ID or see the troubleshooting guide.`
324
+ );
325
+ }
326
+ db.query(
327
+ `INSERT INTO request_receipts
328
+ (namespace, request_id, operation, digest, status, created_at_ms,
329
+ updated_at_ms, plan_json, result_json, ref_json)
330
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
331
+ ).run(
332
+ input.namespace,
333
+ input.requestId,
334
+ input.operation,
335
+ input.digest,
336
+ input.status,
337
+ input.nowMs,
338
+ input.nowMs,
339
+ input.planJson,
340
+ input.resultJson,
341
+ input.refJson
342
+ );
343
+ }).immediate();
344
+ }
345
+
346
+ function updatePlan(
347
+ db: Database,
348
+ namespace: string,
349
+ requestId: string,
350
+ planJson: string,
351
+ nowMs: number
352
+ ): void {
353
+ db.query(
354
+ `UPDATE request_receipts SET plan_json = ?, published = 0, updated_at_ms = ?
355
+ WHERE namespace = ? AND request_id = ? AND status = 'pending'`
356
+ ).run(planJson, nowMs, namespace, requestId);
357
+ }
358
+
359
+ function setPublished(
360
+ db: Database,
361
+ namespace: string,
362
+ requestId: string
363
+ ): void {
364
+ db.query(
365
+ `UPDATE request_receipts SET published = 1
366
+ WHERE namespace = ? AND request_id = ?`
367
+ ).run(namespace, requestId);
368
+ }
369
+
370
+ function deleteRow(db: Database, namespace: string, requestId: string): void {
371
+ db.query(
372
+ `DELETE FROM request_receipts
373
+ WHERE namespace = ? AND request_id = ? AND status = 'pending'`
374
+ ).run(namespace, requestId);
375
+ }
376
+
377
+ function commitRow(
378
+ db: Database,
379
+ namespace: string,
380
+ requestId: string,
381
+ resultJson: string,
382
+ refJson: string,
383
+ nowMs: number
384
+ ): void {
385
+ db.query(
386
+ `UPDATE request_receipts
387
+ SET status = 'committed', plan_json = NULL, result_json = ?,
388
+ ref_json = ?, updated_at_ms = ?
389
+ WHERE namespace = ? AND request_id = ? AND status = 'pending'`
390
+ ).run(resultJson, refJson, nowMs, namespace, requestId);
391
+ }
392
+
393
+ /** Receipt lookup inside one namespace. Never reveals stored outcomes. */
394
+ export async function readRequestStatus(input: {
395
+ ledgerPath: string;
396
+ namespace: string;
397
+ requestId: unknown;
398
+ }): Promise<RequestStatusResult> {
399
+ const requestId = validateRequestId(input.requestId);
400
+ if (!(await Bun.file(input.ledgerPath).exists())) {
401
+ return { requestId, status: "not_found" };
402
+ }
403
+ const db = await openLedger(input.ledgerPath);
404
+ try {
405
+ const row = getRow(db, input.namespace, requestId, Date.now());
406
+ if (!row) return { requestId, status: "not_found" };
407
+ return {
408
+ requestId,
409
+ status: row.status,
410
+ operation: row.operation,
411
+ createdAt: new Date(row.created_at_ms).toISOString(),
412
+ updatedAt: new Date(row.updated_at_ms).toISOString(),
413
+ ...(row.ref_json
414
+ ? { result: JSON.parse(row.ref_json) as RequestResultRef }
415
+ : {}),
416
+ };
417
+ } finally {
418
+ db.close();
419
+ }
420
+ }
421
+
422
+ // ─────────────────────────────────────────────────────────────────────────────
423
+ // Admission / replay service
424
+ // ─────────────────────────────────────────────────────────────────────────────
425
+
426
+ export type RequestCheckpoint =
427
+ | "prepared"
428
+ | "admitted"
429
+ | "published"
430
+ | "synced"
431
+ | "committed";
432
+
433
+ /** Whether a recorded plan's side effect is visible on disk. */
434
+ export type RequestPlanState = "absent" | "published" | "unexpected";
435
+
436
+ export type PreparedRequest<TPlan, TResult> =
437
+ | { plan: TPlan; publish: () => Promise<void> }
438
+ | { result: TResult };
439
+
440
+ export interface RequestedWrite<TPlan, TResult> {
441
+ ledgerPath: string;
442
+ namespace: string;
443
+ requestId: string;
444
+ operation: RequestOperation;
445
+ digest: string;
446
+ lockPath: string;
447
+ lockWaitMs?: number;
448
+ /** Plan against current state under the lease; must not write. Throwing rejects the request. */
449
+ prepare: () => Promise<PreparedRequest<TPlan, TResult>>;
450
+ /** Classify a recorded plan after an interrupted attempt. */
451
+ inspect: (plan: TPlan) => Promise<RequestPlanState>;
452
+ /** Complete a published plan up to the operation's committed boundary. */
453
+ finish: (plan: TPlan) => Promise<TResult>;
454
+ resultRef: (result: TResult) => RequestResultRef;
455
+ now?: () => number;
456
+ /** Deterministic fault injection for tests. */
457
+ checkpoint?: (stage: RequestCheckpoint) => Promise<void> | void;
458
+ }
459
+
460
+ export interface RequestedWriteResult<TResult> {
461
+ result: TResult;
462
+ request: RequestReceiptInfo;
463
+ }
464
+
465
+ function replayTerminal<TResult>(
466
+ row: LedgerRow | null,
467
+ write: Pick<RequestedWrite<unknown, TResult>, "digest" | "requestId">
468
+ ): RequestedWriteResult<TResult> | null {
469
+ if (!row) return null;
470
+ if (row.status === "expired") {
471
+ throw new RequestReceiptError(
472
+ "REQUEST_EXPIRED",
473
+ `Request ${write.requestId} was already used and its receipt has expired; it will not run again. Check the current state before issuing a new request ID.`
474
+ );
475
+ }
476
+ if (row.digest !== write.digest) {
477
+ throw new RequestReceiptError(
478
+ "REQUEST_ID_CONFLICT",
479
+ `Request ${write.requestId} was already used for a different operation or payload. Use a new request ID for a new intent.`
480
+ );
481
+ }
482
+ if (row.status !== "committed") return null;
483
+ return {
484
+ result: JSON.parse(row.result_json ?? "null") as TResult,
485
+ request: {
486
+ requestId: write.requestId,
487
+ status: "committed",
488
+ replayed: true,
489
+ committedAt: new Date(row.updated_at_ms).toISOString(),
490
+ },
491
+ };
492
+ }
493
+
494
+ /** The shared write lease stayed busy past its wait (`LOCKED: ...`). */
495
+ export const isLeaseBusy = (error: unknown): boolean =>
496
+ error instanceof Error &&
497
+ error.message.startsWith(`${MCP_ERRORS.LOCKED.code}:`);
498
+
499
+ async function runUnderLease<TPlan, TResult>(
500
+ write: RequestedWrite<TPlan, TResult>
501
+ ): Promise<RequestedWriteResult<TResult>> {
502
+ const now = write.now ?? Date.now;
503
+ const db = await openLedger(write.ledgerPath);
504
+ try {
505
+ const row = getRow(db, write.namespace, write.requestId, now());
506
+ const replay = replayTerminal<TResult>(row, write);
507
+ if (replay) return replay;
508
+
509
+ // Under the lease a pending row has no live owner: reconcile it.
510
+ let published: TPlan | null = null;
511
+ if (row?.plan_json) {
512
+ const recorded = JSON.parse(row.plan_json) as TPlan;
513
+ let state = await write.inspect(recorded);
514
+ // Our write landed and has since vanished or been reverted: never
515
+ // recreate it behind the user's back.
516
+ if (state === "absent" && row.published === 1) state = "unexpected";
517
+ if (state === "unexpected") {
518
+ throw new RequestReceiptError(
519
+ "REQUEST_RECOVERY_CONFLICT",
520
+ `Request ${write.requestId} was interrupted and its target changed since; nothing was overwritten. Inspect the target and use a new request ID if the change is still wanted.`
521
+ );
522
+ }
523
+ if (state === "published") published = recorded;
524
+ }
525
+
526
+ if (published === null) {
527
+ let prepared: PreparedRequest<TPlan, TResult>;
528
+ try {
529
+ prepared = await write.prepare();
530
+ } catch (error) {
531
+ // Nothing of this request was written: an interrupted receipt that
532
+ // is now rejected is dropped, like any rejection before admission.
533
+ if (row) deleteRow(db, write.namespace, write.requestId);
534
+ throw error;
535
+ }
536
+ await write.checkpoint?.("prepared");
537
+ if ("result" in prepared) {
538
+ if (row) {
539
+ commitRow(
540
+ db,
541
+ write.namespace,
542
+ write.requestId,
543
+ JSON.stringify(prepared.result),
544
+ JSON.stringify(write.resultRef(prepared.result)),
545
+ now()
546
+ );
547
+ } else {
548
+ admitRow(db, {
549
+ namespace: write.namespace,
550
+ requestId: write.requestId,
551
+ operation: write.operation,
552
+ digest: write.digest,
553
+ nowMs: now(),
554
+ status: "committed",
555
+ planJson: null,
556
+ resultJson: JSON.stringify(prepared.result),
557
+ refJson: JSON.stringify(write.resultRef(prepared.result)),
558
+ });
559
+ }
560
+ return {
561
+ result: prepared.result,
562
+ request: {
563
+ requestId: write.requestId,
564
+ status: "committed",
565
+ replayed: false,
566
+ committedAt: new Date(now()).toISOString(),
567
+ },
568
+ };
569
+ }
570
+ const planJson = JSON.stringify(prepared.plan);
571
+ if (row) {
572
+ updatePlan(db, write.namespace, write.requestId, planJson, now());
573
+ } else {
574
+ admitRow(db, {
575
+ namespace: write.namespace,
576
+ requestId: write.requestId,
577
+ operation: write.operation,
578
+ digest: write.digest,
579
+ nowMs: now(),
580
+ status: "pending",
581
+ planJson,
582
+ resultJson: null,
583
+ refJson: null,
584
+ });
585
+ }
586
+ await write.checkpoint?.("admitted");
587
+ try {
588
+ await prepared.publish();
589
+ } catch (error) {
590
+ if ((await write.inspect(prepared.plan)) !== "published") {
591
+ deleteRow(db, write.namespace, write.requestId);
592
+ throw error;
593
+ }
594
+ }
595
+ setPublished(db, write.namespace, write.requestId);
596
+ await write.checkpoint?.("published");
597
+ published = prepared.plan;
598
+ }
599
+
600
+ const result = await write.finish(published);
601
+ await write.checkpoint?.("synced");
602
+ const committedAt = now();
603
+ commitRow(
604
+ db,
605
+ write.namespace,
606
+ write.requestId,
607
+ JSON.stringify(result),
608
+ JSON.stringify(write.resultRef(result)),
609
+ committedAt
610
+ );
611
+ await write.checkpoint?.("committed");
612
+ return {
613
+ result,
614
+ request: {
615
+ requestId: write.requestId,
616
+ status: "committed",
617
+ replayed: false,
618
+ committedAt: new Date(committedAt).toISOString(),
619
+ },
620
+ };
621
+ } finally {
622
+ db.close();
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Run one opted-in mutation: replay a committed receipt, reconcile an
628
+ * interrupted one, or admit and execute a new one — exactly once per
629
+ * (namespace, request ID).
630
+ */
631
+ export async function runRequestedWrite<TPlan, TResult>(
632
+ write: RequestedWrite<TPlan, TResult>
633
+ ): Promise<RequestedWriteResult<TResult>> {
634
+ validateRequestId(write.requestId);
635
+ const lookup = async (): Promise<LedgerRow | null> => {
636
+ if (!(await Bun.file(write.ledgerPath).exists())) return null;
637
+ const db = await openLedger(write.ledgerPath);
638
+ try {
639
+ return getRow(
640
+ db,
641
+ write.namespace,
642
+ write.requestId,
643
+ (write.now ?? Date.now)()
644
+ );
645
+ } finally {
646
+ db.close();
647
+ }
648
+ };
649
+ const replay = replayTerminal<TResult>(await lookup(), write);
650
+ if (replay) return replay;
651
+ try {
652
+ return await withWriteLock(
653
+ write.lockPath,
654
+ () => runUnderLease(write),
655
+ write.lockWaitMs
656
+ );
657
+ } catch (error) {
658
+ if (!isLeaseBusy(error)) throw error;
659
+ // The lease holder may be this request's own earlier attempt.
660
+ const current = await lookup();
661
+ const settled = replayTerminal<TResult>(current, write);
662
+ if (settled) return settled;
663
+ if (current?.status === "pending") {
664
+ throw new RequestReceiptError(
665
+ "REQUEST_PENDING",
666
+ `Request ${write.requestId} is accepted and still in progress; retry the same request ID later.`
667
+ );
668
+ }
669
+ throw error;
670
+ }
671
+ }