@qodeca/xezar 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -15
- package/dist/agent-config/account-identity.d.ts +2 -8
- package/dist/agent-config/account-identity.js +54 -10
- package/dist/agent-config/account-identity.js.map +1 -1
- package/dist/agent-config/catalog.d.ts +49 -7
- package/dist/agent-config/catalog.js +107 -0
- package/dist/agent-config/catalog.js.map +1 -1
- package/dist/agent-config/model-settings/pi.d.ts +12 -6
- package/dist/agent-config/model-settings/pi.js +12 -6
- package/dist/agent-config/model-settings/pi.js.map +1 -1
- package/dist/agent-config/seed.js +10 -0
- package/dist/agent-config/seed.js.map +1 -1
- package/dist/contract/github.d.ts +15 -0
- package/dist/contract/index.d.ts +9 -0
- package/dist/contract/index.js +1527 -895
- package/dist/contract/mcp-api-reference.d.ts +174 -0
- package/dist/contract/mcp-audit.d.ts +103 -0
- package/dist/contract/mcp-discovery.d.ts +257 -0
- package/dist/contract/mcp-event-catalog.d.ts +121 -0
- package/dist/contract/mcp-event-catalog.test.d.ts +1 -0
- package/dist/contract/mcp-idempotency.d.ts +200 -0
- package/dist/contract/mcp-journal.d.ts +268 -0
- package/dist/contract/mcp-leader.d.ts +88 -0
- package/dist/contract/mcp-ownership.d.ts +78 -0
- package/dist/contract/mcp-versioning.d.ts +139 -0
- package/dist/contract/runs.d.ts +81 -0
- package/dist/core/agent-profiles.d.ts +15 -4
- package/dist/core/agent-profiles.js +22 -8
- package/dist/core/agent-profiles.js.map +1 -1
- package/dist/core/opencode-server-runner.js +71 -11
- package/dist/core/opencode-server-runner.js.map +1 -1
- package/dist/core/secret-redaction.d.ts +7 -1
- package/dist/core/secret-redaction.js +44 -10
- package/dist/core/secret-redaction.js.map +1 -1
- package/dist/index.js +138 -27
- package/dist/index.js.map +1 -1
- package/dist/machine-identity.d.ts +66 -0
- package/dist/machine-identity.js +96 -0
- package/dist/machine-identity.js.map +1 -0
- package/dist/mcp/adapters/claude-code.d.ts +41 -0
- package/dist/mcp/adapters/claude-code.js +40 -0
- package/dist/mcp/adapters/claude-code.js.map +1 -0
- package/dist/mcp/adapters/codex.d.ts +140 -0
- package/dist/mcp/adapters/codex.js +323 -0
- package/dist/mcp/adapters/codex.js.map +1 -0
- package/dist/mcp/adapters/opencode.d.ts +148 -0
- package/dist/mcp/adapters/opencode.js +528 -0
- package/dist/mcp/adapters/opencode.js.map +1 -0
- package/dist/mcp/adapters/pi-link.d.ts +102 -0
- package/dist/mcp/adapters/pi-link.js +235 -0
- package/dist/mcp/adapters/pi-link.js.map +1 -0
- package/dist/mcp/adapters/pi.d.ts +188 -0
- package/dist/mcp/adapters/pi.js +418 -0
- package/dist/mcp/adapters/pi.js.map +1 -0
- package/dist/mcp/api-reference.d.ts +26 -0
- package/dist/mcp/api-reference.js +133 -0
- package/dist/mcp/api-reference.js.map +1 -0
- package/dist/mcp/audit-trail.d.ts +162 -0
- package/dist/mcp/audit-trail.js +306 -0
- package/dist/mcp/audit-trail.js.map +1 -0
- package/dist/mcp/bridge.d.ts +81 -0
- package/dist/mcp/bridge.js +407 -0
- package/dist/mcp/bridge.js.map +1 -0
- package/dist/mcp/connection-file.d.ts +49 -0
- package/dist/mcp/connection-file.js +63 -0
- package/dist/mcp/connection-file.js.map +1 -0
- package/dist/mcp/echo-guard.d.ts +94 -0
- package/dist/mcp/echo-guard.js +160 -0
- package/dist/mcp/echo-guard.js.map +1 -0
- package/dist/mcp/event-catalog.d.ts +120 -0
- package/dist/mcp/event-catalog.js +306 -0
- package/dist/mcp/event-catalog.js.map +1 -0
- package/dist/mcp/event-controller.d.ts +230 -0
- package/dist/mcp/event-controller.js +596 -0
- package/dist/mcp/event-controller.js.map +1 -0
- package/dist/mcp/event-journal.d.ts +57 -0
- package/dist/mcp/event-journal.js +423 -0
- package/dist/mcp/event-journal.js.map +1 -0
- package/dist/mcp/index.d.ts +63 -0
- package/dist/mcp/index.js +386 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/ipc.d.ts +202 -0
- package/dist/mcp/ipc.js +206 -0
- package/dist/mcp/ipc.js.map +1 -0
- package/dist/mcp/leader-delivery.d.ts +119 -0
- package/dist/mcp/leader-delivery.js +450 -0
- package/dist/mcp/leader-delivery.js.map +1 -0
- package/dist/mcp/operation-receipts.d.ts +210 -0
- package/dist/mcp/operation-receipts.js +723 -0
- package/dist/mcp/operation-receipts.js.map +1 -0
- package/dist/mcp/project-catalogs.d.ts +23 -0
- package/dist/mcp/project-catalogs.js +25 -0
- package/dist/mcp/project-catalogs.js.map +1 -0
- package/dist/mcp/project-leaders.d.ts +26 -0
- package/dist/mcp/project-leaders.js +17 -0
- package/dist/mcp/project-leaders.js.map +1 -0
- package/dist/mcp/protocol.d.ts +50 -0
- package/dist/mcp/protocol.js +47 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/reconnect.d.ts +297 -0
- package/dist/mcp/reconnect.js +465 -0
- package/dist/mcp/reconnect.js.map +1 -0
- package/dist/mcp/resource-ownership.d.ts +228 -0
- package/dist/mcp/resource-ownership.js +390 -0
- package/dist/mcp/resource-ownership.js.map +1 -0
- package/dist/mcp/service-adapter.d.ts +16488 -0
- package/dist/mcp/service-adapter.js +148 -0
- package/dist/mcp/service-adapter.js.map +1 -0
- package/dist/mcp/service.d.ts +83 -0
- package/dist/mcp/service.js +233 -0
- package/dist/mcp/service.js.map +1 -0
- package/dist/mcp/session-binding.d.ts +105 -0
- package/dist/mcp/session-binding.js +163 -0
- package/dist/mcp/session-binding.js.map +1 -0
- package/dist/mcp/stale-write.d.ts +145 -0
- package/dist/mcp/stale-write.js +219 -0
- package/dist/mcp/stale-write.js.map +1 -0
- package/dist/mcp/tool.d.ts +48 -0
- package/dist/mcp/tool.js +24 -0
- package/dist/mcp/tool.js.map +1 -0
- package/dist/mcp/tools/discovery.d.ts +40 -0
- package/dist/mcp/tools/discovery.js +232 -0
- package/dist/mcp/tools/discovery.js.map +1 -0
- package/dist/mcp/tools/execution-control.d.ts +171 -0
- package/dist/mcp/tools/execution-control.js +513 -0
- package/dist/mcp/tools/execution-control.js.map +1 -0
- package/dist/mcp/tools/handoff-git.d.ts +26 -0
- package/dist/mcp/tools/handoff-git.js +544 -0
- package/dist/mcp/tools/handoff-git.js.map +1 -0
- package/dist/mcp/tools/index.d.ts +2 -0
- package/dist/mcp/tools/index.js +23 -0
- package/dist/mcp/tools/index.js.map +1 -0
- package/dist/mcp/tools/leader-events.d.ts +46 -0
- package/dist/mcp/tools/leader-events.js +126 -0
- package/dist/mcp/tools/leader-events.js.map +1 -0
- package/dist/mcp/tools/local-handoff.d.ts +102 -0
- package/dist/mcp/tools/local-handoff.js +306 -0
- package/dist/mcp/tools/local-handoff.js.map +1 -0
- package/dist/mcp/tools/project-config.d.ts +340 -0
- package/dist/mcp/tools/project-config.js +1075 -0
- package/dist/mcp/tools/project-config.js.map +1 -0
- package/dist/mcp/tools/results-evidence.d.ts +112 -0
- package/dist/mcp/tools/results-evidence.js +1045 -0
- package/dist/mcp/tools/results-evidence.js.map +1 -0
- package/dist/mcp/tools/task-create.d.ts +70 -0
- package/dist/mcp/tools/task-create.js +473 -0
- package/dist/mcp/tools/task-create.js.map +1 -0
- package/dist/mcp/tools/task-reads.d.ts +102 -0
- package/dist/mcp/tools/task-reads.js +689 -0
- package/dist/mcp/tools/task-reads.js.map +1 -0
- package/dist/mcp/tools/work-organisation.d.ts +2 -0
- package/dist/mcp/tools/work-organisation.js +502 -0
- package/dist/mcp/tools/work-organisation.js.map +1 -0
- package/dist/paths.d.ts +17 -7
- package/dist/paths.js +18 -8
- package/dist/paths.js.map +1 -1
- package/dist/runs/project-writer.js +76 -11
- package/dist/runs/project-writer.js.map +1 -1
- package/dist/runs/retention.d.ts +24 -1
- package/dist/runs/retention.js +62 -1
- package/dist/runs/retention.js.map +1 -1
- package/dist/server/forge/github.js +44 -0
- package/dist/server/forge/github.js.map +1 -1
- package/dist/server/forge/types.d.ts +16 -0
- package/dist/server/project-context.d.ts +81 -8
- package/dist/server/project-context.js +143 -35
- package/dist/server/project-context.js.map +1 -1
- package/dist/server/server.d.ts +1050 -53
- package/dist/server/server.js +326 -120
- package/dist/server/server.js.map +1 -1
- package/dist/server/validators.d.ts +26 -6
- package/dist/server/validators.js +17 -0
- package/dist/server/validators.js.map +1 -1
- package/dist/skills-remote.js +64 -6
- package/dist/skills-remote.js.map +1 -1
- package/dist/workflows/run.d.ts +277 -12
- package/dist/workflows/run.js +554 -59
- package/dist/workflows/run.js.map +1 -1
- package/dist/workspace/agent-profiles.d.ts +30 -3
- package/dist/workspace/agent-profiles.js +39 -4
- package/dist/workspace/agent-profiles.js.map +1 -1
- package/dist/workspace/config.js +25 -4
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/project-owner.d.ts +210 -0
- package/dist/workspace/project-owner.js +518 -0
- package/dist/workspace/project-owner.js.map +1 -0
- package/package.json +5 -3
- package/scripts/pi-leader-extension.ts +420 -0
- package/scripts/sync-readme.mjs +83 -2
- package/web/dist/assets/{alert-dialog-BVsVcbFy.js → alert-dialog-D6rP6d39.js} +1 -1
- package/web/dist/assets/arrow-down-DMOfFIzW.js +1 -0
- package/web/dist/assets/arrow-left-C4Ec2BUg.js +1 -0
- package/web/dist/assets/{centered-state-Djgly6Ni.js → centered-state-B0ZqrPaa.js} +12 -12
- package/web/dist/assets/chevron-right-mHJ-crvn.js +1 -0
- package/web/dist/assets/{collapsible-DZ4D-DHG.js → collapsible-DchEHgUM.js} +1 -1
- package/web/dist/assets/{commit-list-B-z1JL9G.js → commit-list-BpMim1Rv.js} +1 -1
- package/web/dist/assets/{compare-variants-D62m4j3B.js → compare-variants-D5ItQbAq.js} +1 -1
- package/web/dist/assets/{diff-BKew9rQX.js → diff-HqqZ_WDR.js} +2 -2
- package/web/dist/assets/{diff-stat-D0mc5V6Z.js → diff-stat-NsU-3ecr.js} +1 -1
- package/web/dist/assets/{diff-view-xpIcuLIC.js → diff-view-B7c11Xux.js} +1 -1
- package/web/dist/assets/{dropdown-menu-CEQziH7X.js → dropdown-menu-CtTpnyod.js} +1 -1
- package/web/dist/assets/{editable-title-BCX3T2BY.js → editable-title-BXtuGQry.js} +1 -1
- package/web/dist/assets/{ellipsis-vertical-ByVdDc4z.js → ellipsis-vertical-MHtGZSH6.js} +1 -1
- package/web/dist/assets/{file-xBSB5hXO.js → file-DKLw5SOb.js} +1 -1
- package/web/dist/assets/{folder-CldgbZ5A.js → folder-Drw6-ufH.js} +1 -1
- package/web/dist/assets/{git-toolbar-DJANqg7Y.js → git-toolbar-BQH8bORV.js} +1 -1
- package/web/dist/assets/github-ynnpKFCJ.js +1 -0
- package/web/dist/assets/{image-preview-Cv5mZr0n.js → image-preview-DLhMN_R9.js} +1 -1
- package/web/dist/assets/index-BEsrNdXt.js +29 -0
- package/web/dist/assets/index-Da8DOBau.css +2 -0
- package/web/dist/assets/{markdown-CgEEE2ND.js → markdown-q365eN6Y.js} +1 -1
- package/web/dist/assets/{new-task-form-1r2bgsdD.js → new-task-form-CW-hNIWQ.js} +1 -1
- package/web/dist/assets/{pill-q-x22_ej.js → pill-B_TFulXa.js} +1 -1
- package/web/dist/assets/{project-router-VDN4jQM8.js → project-router-CLPc-ImZ.js} +1 -1
- package/web/dist/assets/{prompt-templates-BmZtCe-L.js → prompt-templates-CLc4RxyO.js} +1 -1
- package/web/dist/assets/{repo-git-btG0sdbO.js → repo-git-Is7R6-Tj.js} +1 -1
- package/web/dist/assets/{run-diff-vguEMJzh.js → run-diff-DHMvDlVo.js} +2 -2
- package/web/dist/assets/run-header-DvZJbQ8I.js +1 -0
- package/web/dist/assets/{search-x-QmhaAbg5.js → search-x-Ce2MMetu.js} +1 -1
- package/web/dist/assets/{skill-empty-hint-BrzUwByZ.js → skill-empty-hint-C_xCSvzB.js} +1 -1
- package/web/dist/assets/skills-CivqD4si.js +1 -0
- package/web/dist/assets/{sparkles-CP8QDOgX.js → sparkles-BCqPoj5g.js} +1 -1
- package/web/dist/assets/{square-terminal-CGypshSD.js → square-terminal-ikj5g-oK.js} +1 -1
- package/web/dist/assets/{tab-link-D4lFzzI1.js → tab-link-VsEjFV1Y.js} +1 -1
- package/web/dist/assets/{task-changes-BtZId14z.js → task-changes-CH-E81-_.js} +1 -1
- package/web/dist/assets/{task-commits-BoeyJXrv.js → task-commits-DqfkMFxp.js} +1 -1
- package/web/dist/assets/{task-files-DIy2oUnj.js → task-files-D_nkHZ_a.js} +2 -2
- package/web/dist/assets/task-thread-bxQompxJ.js +9 -0
- package/web/dist/assets/{textarea-CyBCyKIk.js → textarea-xajuW1L_.js} +1 -1
- package/web/dist/assets/{thread-loading-Cuq2jchC.js → thread-loading-C3JQZbIE.js} +1 -1
- package/web/dist/assets/{trash-2-B6Dp3ZNs.js → trash-2-hJrBhpUD.js} +1 -1
- package/web/dist/assets/{triangle-alert-xBya8FUp.js → triangle-alert-D1u82y1a.js} +1 -1
- package/web/dist/assets/{upload-D6MXEB9p.js → upload-Bn6FBZ59.js} +1 -1
- package/web/dist/assets/{use-desktop-CPBwzQ7M.js → use-desktop-DOIMfHfY.js} +1 -1
- package/web/dist/assets/{use-submit-shortcut-JTpNzU-u.js → use-submit-shortcut-Dj4DHTdE.js} +1 -1
- package/web/dist/assets/utils-YwBpOoRN.js +64 -0
- package/web/dist/assets/{workflows-DG115scU.js → workflows-CTaAKz2m.js} +3 -3
- package/web/dist/assets/{zoomable-image-GTYwLtkS.js → zoomable-image-C1-6P3tg.js} +1 -1
- package/web/dist/index.html +23 -23
- package/web/dist/assets/arrow-down-CYgAlZtW.js +0 -1
- package/web/dist/assets/arrow-left-BOqE0a3k.js +0 -1
- package/web/dist/assets/chevron-right-Cc9BWnbN.js +0 -1
- package/web/dist/assets/github-D6vK0jFn.js +0 -1
- package/web/dist/assets/index-CkmR6h7s.css +0 -2
- package/web/dist/assets/index-DVDqF4bM.js +0 -9
- package/web/dist/assets/refresh-cw-DcNCj2nu.js +0 -1
- package/web/dist/assets/run-header-DM7MiKj9.js +0 -1
- package/web/dist/assets/skills-DMfxQtrD.js +0 -1
- package/web/dist/assets/task-thread-CbMPmrWq.js +0 -9
- package/web/dist/assets/utils--z9hRtl3.js +0 -64
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable operation-key idempotency for MCP mutations (issue #101).
|
|
3
|
+
*
|
|
4
|
+
* Implements decision D-06 (`docs/features/mcp-server/mcp-d06-versioning-idempotency-audit-decision.md`,
|
|
5
|
+
* cited below as "D-06 § n"). Every mutating MCP tool carries a client-generated `operationId`; this
|
|
6
|
+
* store keys a receipt on `<projectId>/<operationId>`, verifies the stored action AND payload digest
|
|
7
|
+
* on every retry, and answers each call with exactly one of the § 6 results:
|
|
8
|
+
*
|
|
9
|
+
* miss → execute: `intent` line, effect, `settled` line
|
|
10
|
+
* settled, same action + digest → replay the stored outcome; no effect
|
|
11
|
+
* intent, live in this process → `in-progress`; no effect
|
|
12
|
+
* intent, not live (a crash happened) → reconcile; `unverified` unless a read settles it; no effect
|
|
13
|
+
* different action or digest → `operation_key_conflict`; no effect, no stored result
|
|
14
|
+
*
|
|
15
|
+
* Three traps this module exists to avoid, all named by the requirements:
|
|
16
|
+
* - deduplicating by TEXT: the digest is taken over the zod-PARSED payload, canonicalised
|
|
17
|
+
* (`payloadDigest`), never over raw JSON-RPC params or a prompt string;
|
|
18
|
+
* - trusting the JSON-RPC request id: it never reaches this module — identity is the
|
|
19
|
+
* application-level `operationId`, which survives reconnects and restarts;
|
|
20
|
+
* - repeating blindly after a crash: the `intent` line is appended SYNCHRONOUSLY before the
|
|
21
|
+
* effect, so a crash between effect and receipt leaves a dangling intent that reads as
|
|
22
|
+
* `unverified` — the effect is never run a second time for that key.
|
|
23
|
+
*
|
|
24
|
+
* Storage is two files in the project's data directory, both deletable state: deleting them
|
|
25
|
+
* discards idempotency history and nothing else, and xezar keeps working. They are written, never
|
|
26
|
+
* required. Nothing here runs at boot unless a caller opens the store, and opening never throws.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
29
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import { UNVERIFIED_GUIDANCE, operationIdSchema, operationOutcomeSchema, operationReasonSchema, operationResultRefSchema, reconcilePredicateSchema, } from '../contract/index.js';
|
|
33
|
+
import { atomicTmpPath } from '../workspace/config.js';
|
|
34
|
+
import { AUTO_RESUME_MISSED_WINDOW_MS, MAX_AUTO_RESUMES } from '../workflows/run.js';
|
|
35
|
+
/** The append-only journal: one line per phase transition. The durable record (D-06 § 7.2). */
|
|
36
|
+
export const RECEIPT_JOURNAL_FILE = 'mcp-operations.ndjson';
|
|
37
|
+
/** The compacted snapshot: a load-time shortcut, never the source of truth (D-06 § 7.2). */
|
|
38
|
+
export const RECEIPT_SNAPSHOT_FILE = 'mcp-operations.json';
|
|
39
|
+
/**
|
|
40
|
+
* The provider usage window each automatic resume waits out. Read from `run.ts`'s own comments
|
|
41
|
+
* ("a couple of days of five-hour windows"), not from a provider contract — D-06 § 8.2 records
|
|
42
|
+
* that limit. If it moves, the retention floor below moves with it.
|
|
43
|
+
*/
|
|
44
|
+
const PROVIDER_WINDOW_MS = 5 * 60 * 60_000;
|
|
45
|
+
/**
|
|
46
|
+
* Receipt age floor: 12 resumes × 5 h + the 24 h missed-deadline tolerance = 84 h (D-06 § 8.2) —
|
|
47
|
+
* the longest span over which the engine itself may still resurrect a run one MCP mutation
|
|
48
|
+
* started. Derived from the live constants, so the derivation IS the decision.
|
|
49
|
+
*/
|
|
50
|
+
export const RECEIPT_MAX_AGE_MS = MAX_AUTO_RESUMES * PROVIDER_WINDOW_MS + AUTO_RESUME_MISSED_WINDOW_MS;
|
|
51
|
+
/** Receipt count floor per project: a measured 40.6 ms cold scan against a 300 ms budget (D-06 § 8.3). */
|
|
52
|
+
export const RECEIPT_MAX_KEPT = 50_000;
|
|
53
|
+
/** Snapshot cadence: a 1 000-line journal scans in 0.7 ms, so compacting sooner does not pay (D-06 § 7.2). */
|
|
54
|
+
export const RECEIPT_SNAPSHOT_EVERY_LINES = 1_000;
|
|
55
|
+
/**
|
|
56
|
+
* The UUIDv5 namespace MCP-created run ids are derived in (D-06 § 12.1). A constant, never
|
|
57
|
+
* regenerated: changing it would make every recorded prediction point at an id nobody creates.
|
|
58
|
+
*/
|
|
59
|
+
export const XEZAR_MCP_NAMESPACE = 'a3c1e7d2-5b4f-4e8a-9c61-2f0d8b7e4a15';
|
|
60
|
+
/**
|
|
61
|
+
* One persisted receipt line (D-06 § 7.1). Every line is self-contained — the `settled` line
|
|
62
|
+
* repeats the identity fields — so the load-time fold is "latest line per key wins".
|
|
63
|
+
*
|
|
64
|
+
* `.passthrough()` so a key a NEWER xezar wrote survives this version's compaction rewrite: an
|
|
65
|
+
* optional field that is stripped on load is erased on the next save, which is the trap D-06
|
|
66
|
+
* § 13.3 records against `runRecordSchema`. An unknown `v` fails the literal and the line is
|
|
67
|
+
* quarantined rather than guessed at — quarantine is the safe direction (§ 7.4).
|
|
68
|
+
*/
|
|
69
|
+
const receiptSchema = z
|
|
70
|
+
.object({
|
|
71
|
+
v: z.literal(1),
|
|
72
|
+
key: z.string().min(1),
|
|
73
|
+
action: z.string().min(1),
|
|
74
|
+
payloadDigest: z.string().regex(/^[0-9a-f]{64}$/),
|
|
75
|
+
phase: z.enum(['intent', 'settled']),
|
|
76
|
+
outcome: operationOutcomeSchema.optional(),
|
|
77
|
+
resultRef: operationResultRefSchema.optional(),
|
|
78
|
+
errorCode: operationReasonSchema.optional(),
|
|
79
|
+
expectedVersion: z.string().optional(),
|
|
80
|
+
origin: z.literal('mcp'),
|
|
81
|
+
/** The D-02 session fencing token the mutation arrived under. Recorded, not interpreted here. */
|
|
82
|
+
ownerGeneration: z.string().optional(),
|
|
83
|
+
reconcile: reconcilePredicateSchema.optional(),
|
|
84
|
+
startedAt: z.string().min(1),
|
|
85
|
+
settledAt: z.string().optional(),
|
|
86
|
+
})
|
|
87
|
+
.passthrough()
|
|
88
|
+
.refine((r) => (r.phase === 'settled') === (r.outcome !== undefined), {
|
|
89
|
+
message: 'a settled receipt carries an outcome and an intent never does',
|
|
90
|
+
});
|
|
91
|
+
/** First line of a compacted journal. Its generation is what binds a snapshot to one journal. */
|
|
92
|
+
const headerSchema = z.object({ v: z.literal(1), journal: z.object({ generation: z.string().min(1) }) });
|
|
93
|
+
/** Carries a quarantine count across a compaction rewrite until the age floor has passed. */
|
|
94
|
+
const damageSchema = z.object({
|
|
95
|
+
v: z.literal(1),
|
|
96
|
+
damage: z.object({ lines: z.number().int().positive(), detectedAt: z.string().min(1) }),
|
|
97
|
+
});
|
|
98
|
+
const snapshotSchema = z.object({
|
|
99
|
+
v: z.literal(1),
|
|
100
|
+
generation: z.string().min(1),
|
|
101
|
+
/** Journal byte offset this snapshot covers; the scan resumes from here (D-06 § 7.4). */
|
|
102
|
+
offset: z.number().int().nonnegative(),
|
|
103
|
+
receipts: z.array(receiptSchema),
|
|
104
|
+
damage: damageSchema.shape.damage.optional(),
|
|
105
|
+
});
|
|
106
|
+
/** `<projectId>/<operationId>` (D-06 § 5.2). The id alphabet has no `/`, so the split is unambiguous. */
|
|
107
|
+
export function operationKey(projectId, operationId) {
|
|
108
|
+
return `${projectId}/${operationId}`;
|
|
109
|
+
}
|
|
110
|
+
function projectOfKey(key) {
|
|
111
|
+
return key.slice(0, key.lastIndexOf('/'));
|
|
112
|
+
}
|
|
113
|
+
/** RFC 4122 § 4.3 name-based UUID, SHA-1 variant. */
|
|
114
|
+
export function uuidV5(name, namespace) {
|
|
115
|
+
const ns = Buffer.from(namespace.replace(/-/g, ''), 'hex');
|
|
116
|
+
if (ns.length !== 16)
|
|
117
|
+
throw new TypeError(`not a UUID namespace: ${namespace}`);
|
|
118
|
+
const bytes = createHash('sha1').update(ns).update(name, 'utf8').digest().subarray(0, 16);
|
|
119
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
120
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
121
|
+
const hex = bytes.toString('hex');
|
|
122
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The run id an MCP `runs.create` must create, derived from the operation key before the effect
|
|
126
|
+
* (D-06 § 12.1). A retry predicts the same id; two operations cannot share one. Reconciliation is
|
|
127
|
+
* then a primary-key lookup and no new field is persisted on the run record.
|
|
128
|
+
*/
|
|
129
|
+
export function predictRunId(projectId, operationId) {
|
|
130
|
+
return uuidV5(operationKey(projectId, operationId), XEZAR_MCP_NAMESPACE);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* `sha256(canonical(payload))`, lowercase hex (D-06 § 5.4). The payload must already be the
|
|
134
|
+
* action's zod-parsed value. Canonical form: object keys sorted by UTF-16 code unit at every level,
|
|
135
|
+
* arrays in order, no whitespace, `undefined` object keys dropped, and every binary blob replaced
|
|
136
|
+
* by `{ bytes, sha256 }` so the digest covers the BYTES, never a path. Non-finite numbers and
|
|
137
|
+
* values JSON cannot carry are refused rather than silently coerced.
|
|
138
|
+
*/
|
|
139
|
+
export function payloadDigest(payload, digestExclude = []) {
|
|
140
|
+
let value = payload;
|
|
141
|
+
if (digestExclude.length > 0 && isPlainObject(payload)) {
|
|
142
|
+
value = Object.fromEntries(Object.entries(payload).filter(([key]) => !digestExclude.includes(key)));
|
|
143
|
+
}
|
|
144
|
+
return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex');
|
|
145
|
+
}
|
|
146
|
+
function isPlainObject(value) {
|
|
147
|
+
if (value === null || typeof value !== 'object')
|
|
148
|
+
return false;
|
|
149
|
+
const proto = Object.getPrototypeOf(value);
|
|
150
|
+
return proto === Object.prototype || proto === null;
|
|
151
|
+
}
|
|
152
|
+
function canonicalJson(value) {
|
|
153
|
+
if (value === null || value === undefined)
|
|
154
|
+
return 'null';
|
|
155
|
+
switch (typeof value) {
|
|
156
|
+
case 'boolean':
|
|
157
|
+
return value ? 'true' : 'false';
|
|
158
|
+
case 'string':
|
|
159
|
+
return JSON.stringify(value);
|
|
160
|
+
case 'number':
|
|
161
|
+
if (!Number.isFinite(value))
|
|
162
|
+
throw new TypeError('payload digest: non-finite number');
|
|
163
|
+
return JSON.stringify(value);
|
|
164
|
+
case 'object':
|
|
165
|
+
break;
|
|
166
|
+
default:
|
|
167
|
+
throw new TypeError(`payload digest: unsupported ${typeof value}`);
|
|
168
|
+
}
|
|
169
|
+
if (value instanceof Uint8Array) {
|
|
170
|
+
return canonicalJson({ bytes: value.byteLength, sha256: createHash('sha256').update(value).digest('hex') });
|
|
171
|
+
}
|
|
172
|
+
if (value instanceof Date)
|
|
173
|
+
return JSON.stringify(value.toISOString());
|
|
174
|
+
if (Array.isArray(value))
|
|
175
|
+
return `[${value.map((item) => canonicalJson(item)).join(',')}]`;
|
|
176
|
+
if (!isPlainObject(value))
|
|
177
|
+
throw new TypeError('payload digest: unsupported object type');
|
|
178
|
+
const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
|
|
179
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* `run-by-key` (D-06 § 9.3): the store write IS the effect, so a run under the predicted id means
|
|
183
|
+
* `ok` and its absence means `not-applied` — but only while the index can still answer. The run
|
|
184
|
+
* store evicts by COUNT, so a busy project may already have pruned the very run an old intent
|
|
185
|
+
* names; "not there" and "the store can no longer tell" must not share a branch.
|
|
186
|
+
*
|
|
187
|
+
* The index can answer only if the intent is no older than the oldest run each retention pool
|
|
188
|
+
* still holds (archived and live runs are pruned separately, so the newest of the two pool-oldest
|
|
189
|
+
* dates is the conservative bound). An empty index cannot answer at all — it may be a deleted or
|
|
190
|
+
* corrupt `runs.json` — so it is `unverified` too. Both errors fall on the safe side: a spurious
|
|
191
|
+
* "check this" is recoverable, a duplicated task is not.
|
|
192
|
+
*/
|
|
193
|
+
export function runByKeyReconciler(store) {
|
|
194
|
+
return (predicate, context) => {
|
|
195
|
+
if (predicate.kind !== 'run-by-key')
|
|
196
|
+
return { verdict: 'unverified', reason: 'wrong predicate' };
|
|
197
|
+
const found = store.getRun(predicate.predictedRunId);
|
|
198
|
+
if (found)
|
|
199
|
+
return { verdict: 'ok', resultRef: { kind: 'run', id: found.id } };
|
|
200
|
+
let bound;
|
|
201
|
+
for (const archived of [false, true]) {
|
|
202
|
+
let oldest;
|
|
203
|
+
for (const run of store.listRuns()) {
|
|
204
|
+
if ((run.archived ?? false) !== archived)
|
|
205
|
+
continue;
|
|
206
|
+
if (oldest === undefined || run.createdAt < oldest)
|
|
207
|
+
oldest = run.createdAt;
|
|
208
|
+
}
|
|
209
|
+
if (oldest !== undefined && (bound === undefined || oldest > bound))
|
|
210
|
+
bound = oldest;
|
|
211
|
+
}
|
|
212
|
+
if (bound === undefined || context.startedAt < bound) {
|
|
213
|
+
return { verdict: 'unverified', reason: 'run index pruned' };
|
|
214
|
+
}
|
|
215
|
+
return { verdict: 'not-applied' };
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function errorMessage(err) {
|
|
219
|
+
return err instanceof Error ? err.message : String(err);
|
|
220
|
+
}
|
|
221
|
+
/** A reason the wire may carry. Anything else — a raw error body above all — becomes a fixed code. */
|
|
222
|
+
function safeReason(reason, fallback) {
|
|
223
|
+
return operationReasonSchema.safeParse(reason).success ? reason : fallback;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The per-project receipt store. One instance per project data directory, owned by the process
|
|
227
|
+
* `ownProjectData` admits as that project's writer — which is why no cross-process lock is needed
|
|
228
|
+
* around the check-then-write (D-06 § 4.5).
|
|
229
|
+
*/
|
|
230
|
+
export class OperationReceiptStore {
|
|
231
|
+
dataDir;
|
|
232
|
+
opts;
|
|
233
|
+
receipts = new Map();
|
|
234
|
+
/** Keys whose effect (or reconciliation) is running in THIS process right now. */
|
|
235
|
+
live = new Map();
|
|
236
|
+
warned = new Set();
|
|
237
|
+
journalPath;
|
|
238
|
+
snapshotPath;
|
|
239
|
+
now;
|
|
240
|
+
maxAgeMs;
|
|
241
|
+
maxKept;
|
|
242
|
+
snapshotEveryLines;
|
|
243
|
+
/** Lines appended (or scanned past the snapshot) since the last compaction. */
|
|
244
|
+
linesSinceCompaction = 0;
|
|
245
|
+
/** Journal lines that failed to parse. While > 0, a key miss reconciles before executing (§ 7.4). */
|
|
246
|
+
quarantined = 0;
|
|
247
|
+
damageDetectedAt;
|
|
248
|
+
/** Whether the journal already exists with content; a fresh journal starts with a header line. */
|
|
249
|
+
journalStarted = false;
|
|
250
|
+
/** The journal ends in a torn line: the next append must start on a fresh line, or the torn
|
|
251
|
+
* fragment would swallow a good receipt into the quarantine with it. */
|
|
252
|
+
needsNewline = false;
|
|
253
|
+
constructor(dataDir, opts) {
|
|
254
|
+
this.dataDir = dataDir;
|
|
255
|
+
this.opts = opts;
|
|
256
|
+
this.journalPath = join(dataDir, RECEIPT_JOURNAL_FILE);
|
|
257
|
+
this.snapshotPath = join(dataDir, RECEIPT_SNAPSHOT_FILE);
|
|
258
|
+
this.now = opts.now ?? Date.now;
|
|
259
|
+
this.maxAgeMs = opts.maxAgeMs ?? RECEIPT_MAX_AGE_MS;
|
|
260
|
+
this.maxKept = opts.maxKept ?? RECEIPT_MAX_KEPT;
|
|
261
|
+
this.snapshotEveryLines = opts.snapshotEveryLines ?? RECEIPT_SNAPSHOT_EVERY_LINES;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Open (or create on first write) the receipts for one project data directory. Never throws:
|
|
265
|
+
* absent files are an empty store, an unreadable snapshot is ignored, and unreadable journal
|
|
266
|
+
* lines are quarantined one by one — a torn final line costs exactly that one receipt (§ 7.4).
|
|
267
|
+
*/
|
|
268
|
+
static open(dataDir, opts = {}) {
|
|
269
|
+
const store = new OperationReceiptStore(dataDir, opts);
|
|
270
|
+
store.load();
|
|
271
|
+
return store;
|
|
272
|
+
}
|
|
273
|
+
/** Counts for diagnostics and tests. */
|
|
274
|
+
stats() {
|
|
275
|
+
return { receipts: this.receipts.size, quarantined: this.quarantined, live: this.live.size };
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Run one mutation under its operation key, answering with exactly one § 6 result.
|
|
279
|
+
*
|
|
280
|
+
* Everything up to the intent append is synchronous, so two calls with one key cannot both pass
|
|
281
|
+
* the lookup: the second sees the first as live and answers `in-progress`.
|
|
282
|
+
*/
|
|
283
|
+
async execute(request) {
|
|
284
|
+
const operationId = operationIdSchema.parse(request.operationId);
|
|
285
|
+
if (request.projectId.length === 0)
|
|
286
|
+
throw new TypeError('operation receipts: empty projectId');
|
|
287
|
+
const key = operationKey(request.projectId, operationId);
|
|
288
|
+
const digest = payloadDigest(request.payload, request.digestExclude);
|
|
289
|
+
const reconcile = reconcilePredicateSchema.parse(request.reconcile);
|
|
290
|
+
const stored = this.receipts.get(key);
|
|
291
|
+
if (stored) {
|
|
292
|
+
if (stored.action !== request.action || stored.payloadDigest !== digest) {
|
|
293
|
+
return {
|
|
294
|
+
error: 'operation_key_conflict',
|
|
295
|
+
operationId,
|
|
296
|
+
storedAction: stored.action,
|
|
297
|
+
storedAt: stored.startedAt,
|
|
298
|
+
mismatch: stored.action !== request.action ? 'action' : 'payload',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (stored.phase === 'settled')
|
|
302
|
+
return this.settledAnswer(stored, operationId, true);
|
|
303
|
+
const startedAt = this.live.get(key);
|
|
304
|
+
if (startedAt !== undefined) {
|
|
305
|
+
return { status: 'in-progress', operationId, action: stored.action, startedAt: stored.startedAt };
|
|
306
|
+
}
|
|
307
|
+
// A dangling intent with nothing live: the only way here is a crash between the effect and
|
|
308
|
+
// its receipt. Reconcile on demand; never run the effect again (§ 6, § 9).
|
|
309
|
+
return this.reconcileDangling(key, stored, operationId);
|
|
310
|
+
}
|
|
311
|
+
if (this.live.has(key)) {
|
|
312
|
+
// A reconciliation for this key is already in flight (damaged-journal miss below).
|
|
313
|
+
return { status: 'in-progress', operationId, action: request.action, startedAt: this.live.get(key) };
|
|
314
|
+
}
|
|
315
|
+
const startedAt = new Date(this.now()).toISOString();
|
|
316
|
+
if (this.quarantined > 0) {
|
|
317
|
+
// A quarantined line may have been this very key's intent, and a miss must not become a
|
|
318
|
+
// licence to execute an operation that already happened (§ 7.4). Ask the reconciler first.
|
|
319
|
+
this.live.set(key, startedAt);
|
|
320
|
+
let verdict;
|
|
321
|
+
try {
|
|
322
|
+
verdict = await this.runReconciler(reconcile, { action: request.action, startedAt });
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
this.live.delete(key);
|
|
326
|
+
}
|
|
327
|
+
if (verdict.verdict === 'ok') {
|
|
328
|
+
// It had happened: settle it as it would have been settled, and replay.
|
|
329
|
+
const receipt = this.settledReceipt(key, request, digest, startedAt, {
|
|
330
|
+
outcome: 'ok',
|
|
331
|
+
resultRef: verdict.resultRef,
|
|
332
|
+
});
|
|
333
|
+
this.appendBestEffort(receipt);
|
|
334
|
+
return this.settledAnswer(receipt, operationId, true);
|
|
335
|
+
}
|
|
336
|
+
if (verdict.verdict === 'unverified') {
|
|
337
|
+
return this.unverifiedAnswer(operationId, request.action, startedAt, reconcile.kind, {
|
|
338
|
+
attemptedAt: new Date(this.now()).toISOString(),
|
|
339
|
+
reason: 'journal_damaged',
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
// `not-applied`: the reconciler positively established no earlier effect, so executing now
|
|
343
|
+
// cannot duplicate anything. Fall through to a normal first attempt.
|
|
344
|
+
}
|
|
345
|
+
// § 7.3 step 2: the version/permission check, before any intent.
|
|
346
|
+
const refusal = request.precheck?.();
|
|
347
|
+
if (refusal !== undefined) {
|
|
348
|
+
const receipt = this.settledReceipt(key, request, digest, startedAt, {
|
|
349
|
+
outcome: 'rejected',
|
|
350
|
+
errorCode: safeReason(refusal, 'rejected'),
|
|
351
|
+
});
|
|
352
|
+
this.appendBestEffort(receipt);
|
|
353
|
+
return this.settledAnswer(receipt, operationId, false);
|
|
354
|
+
}
|
|
355
|
+
// § 7.3 step 3: the intent, synchronously, BEFORE the effect. If it cannot be written the
|
|
356
|
+
// operation cannot be made idempotent, so it is refused rather than run (§ 7.5).
|
|
357
|
+
const intent = {
|
|
358
|
+
v: 1,
|
|
359
|
+
key,
|
|
360
|
+
action: request.action,
|
|
361
|
+
payloadDigest: digest,
|
|
362
|
+
phase: 'intent',
|
|
363
|
+
...(request.expectedVersion !== undefined ? { expectedVersion: request.expectedVersion } : {}),
|
|
364
|
+
origin: 'mcp',
|
|
365
|
+
...(request.ownerGeneration !== undefined ? { ownerGeneration: request.ownerGeneration } : {}),
|
|
366
|
+
reconcile,
|
|
367
|
+
startedAt,
|
|
368
|
+
};
|
|
369
|
+
try {
|
|
370
|
+
this.append(intent);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
this.warnOnce('journal', `[xez] MCP operation journal unwritable (${errorMessage(err)}) — mutations are refused until it is writable`);
|
|
374
|
+
return { error: 'operation_receipt_unavailable', operationId, reason: 'journal_unwritable' };
|
|
375
|
+
}
|
|
376
|
+
this.receipts.set(key, intent);
|
|
377
|
+
this.live.set(key, startedAt);
|
|
378
|
+
// § 7.3 step 4: the effect.
|
|
379
|
+
let result;
|
|
380
|
+
try {
|
|
381
|
+
result = await request.effect();
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// The effect may or may not have happened before it threw. That is exactly the dangling
|
|
385
|
+
// intent case, so it gets the same answer: reconcile, never repeat.
|
|
386
|
+
this.live.delete(key);
|
|
387
|
+
return this.reconcileDangling(key, intent, operationId);
|
|
388
|
+
}
|
|
389
|
+
this.live.delete(key);
|
|
390
|
+
// § 7.3 step 5: the settled line.
|
|
391
|
+
const settled = this.settledReceipt(key, request, digest, startedAt, result.outcome === 'ok'
|
|
392
|
+
? { outcome: 'ok', resultRef: operationResultRefSchema.parse(result.resultRef) }
|
|
393
|
+
: { outcome: result.outcome, errorCode: safeReason(result.errorCode, result.outcome) });
|
|
394
|
+
// The effect already happened, so a failed receipt write must not turn success into an error.
|
|
395
|
+
// Held in memory, this process still replays it; after a restart the dangling intent reads as
|
|
396
|
+
// `unverified` and is reconciled — the documented cost of a journal that stopped accepting writes.
|
|
397
|
+
this.appendBestEffort(settled);
|
|
398
|
+
this.maybeCompact();
|
|
399
|
+
return this.settledAnswer(settled, operationId, false);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Reconcile every dangling intent once, in the background (D-06 § 9.3 rule 1). Callers start it
|
|
403
|
+
* after project open and never await it: a `gh`-less or offline machine keeps working, and until
|
|
404
|
+
* a receipt's reconciliation finishes it truthfully reads `unverified`.
|
|
405
|
+
*/
|
|
406
|
+
async reconcilePending() {
|
|
407
|
+
for (const [key, receipt] of [...this.receipts]) {
|
|
408
|
+
if (receipt.phase !== 'intent' || this.live.has(key))
|
|
409
|
+
continue;
|
|
410
|
+
const operationId = key.slice(key.lastIndexOf('/') + 1);
|
|
411
|
+
await this.reconcileDangling(key, receipt, operationId);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/** Clean shutdown: compact, so the next open reads a snapshot instead of the whole journal. */
|
|
415
|
+
close() {
|
|
416
|
+
if (this.linesSinceCompaction > 0 || this.quarantined > 0)
|
|
417
|
+
this.compact();
|
|
418
|
+
}
|
|
419
|
+
async reconcileDangling(key, intent, operationId) {
|
|
420
|
+
const predicate = intent.reconcile ?? { kind: 'none' };
|
|
421
|
+
this.live.set(key, intent.startedAt);
|
|
422
|
+
let verdict;
|
|
423
|
+
try {
|
|
424
|
+
verdict = await this.runReconciler(predicate, { action: intent.action, startedAt: intent.startedAt });
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
this.live.delete(key);
|
|
428
|
+
}
|
|
429
|
+
const attemptedAt = new Date(this.now()).toISOString();
|
|
430
|
+
if (verdict.verdict === 'unverified') {
|
|
431
|
+
// Stays unverified on disk — the intent line is left as it is and never decays with age. Every
|
|
432
|
+
// retry reconciles again and returns the evidence of that attempt (§ 9.3 rule 4).
|
|
433
|
+
return this.unverifiedAnswer(operationId, intent.action, intent.startedAt, predicate.kind, {
|
|
434
|
+
attemptedAt,
|
|
435
|
+
reason: verdict.reason,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
// A read established the outcome with certainty: settle it, so every later retry replays it.
|
|
439
|
+
const current = this.receipts.get(key);
|
|
440
|
+
if (current && current.phase === 'settled')
|
|
441
|
+
return this.settledAnswer(current, operationId, true);
|
|
442
|
+
const settled = {
|
|
443
|
+
...intent,
|
|
444
|
+
phase: 'settled',
|
|
445
|
+
...(verdict.verdict === 'ok'
|
|
446
|
+
? { outcome: 'ok', resultRef: verdict.resultRef }
|
|
447
|
+
: { outcome: 'not-applied', errorCode: 'reconciled not applied' }),
|
|
448
|
+
settledAt: attemptedAt,
|
|
449
|
+
};
|
|
450
|
+
delete settled.reconcile;
|
|
451
|
+
this.appendBestEffort(settled);
|
|
452
|
+
return this.settledAnswer(settled, operationId, true);
|
|
453
|
+
}
|
|
454
|
+
async runReconciler(predicate, context) {
|
|
455
|
+
const reconciler = this.opts.reconcilers?.[predicate.kind];
|
|
456
|
+
if (!reconciler)
|
|
457
|
+
return { verdict: 'unverified', reason: 'no reconciler' };
|
|
458
|
+
try {
|
|
459
|
+
const verdict = await reconciler(predicate, context);
|
|
460
|
+
if (verdict.verdict === 'unverified') {
|
|
461
|
+
return { verdict: 'unverified', reason: safeReason(verdict.reason, 'reconciler_failed') };
|
|
462
|
+
}
|
|
463
|
+
if (verdict.verdict === 'ok') {
|
|
464
|
+
const ref = operationResultRefSchema.safeParse(verdict.resultRef);
|
|
465
|
+
return ref.success ? { verdict: 'ok', resultRef: ref.data } : { verdict: 'unverified', reason: 'reconciler_failed' };
|
|
466
|
+
}
|
|
467
|
+
return verdict.verdict === 'not-applied' ? verdict : { verdict: 'unverified', reason: 'reconciler_failed' };
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// A thrown reconciler is a reconciler that could not ask — `unverified`, never `not-applied`.
|
|
471
|
+
return { verdict: 'unverified', reason: 'reconciler_failed' };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
settledReceipt(key, request, digest, startedAt, result) {
|
|
475
|
+
return {
|
|
476
|
+
v: 1,
|
|
477
|
+
key,
|
|
478
|
+
action: request.action,
|
|
479
|
+
payloadDigest: digest,
|
|
480
|
+
phase: 'settled',
|
|
481
|
+
...result,
|
|
482
|
+
...(request.expectedVersion !== undefined ? { expectedVersion: request.expectedVersion } : {}),
|
|
483
|
+
origin: 'mcp',
|
|
484
|
+
...(request.ownerGeneration !== undefined ? { ownerGeneration: request.ownerGeneration } : {}),
|
|
485
|
+
startedAt,
|
|
486
|
+
settledAt: new Date(this.now()).toISOString(),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
settledAnswer(receipt, operationId, replayed) {
|
|
490
|
+
if (receipt.outcome === 'unverified') {
|
|
491
|
+
return this.unverifiedAnswer(operationId, receipt.action, receipt.startedAt, 'none', {
|
|
492
|
+
reason: receipt.errorCode ?? 'unverified',
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
status: receipt.outcome ?? 'rejected',
|
|
497
|
+
operationId,
|
|
498
|
+
action: receipt.action,
|
|
499
|
+
replayed,
|
|
500
|
+
...(receipt.resultRef ? { resultRef: receipt.resultRef } : {}),
|
|
501
|
+
...(receipt.errorCode ? { errorCode: receipt.errorCode } : {}),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
unverifiedAnswer(operationId, action, startedAt, kind, evidence) {
|
|
505
|
+
return {
|
|
506
|
+
status: 'unverified',
|
|
507
|
+
operationId,
|
|
508
|
+
action,
|
|
509
|
+
startedAt,
|
|
510
|
+
reconcile: {
|
|
511
|
+
kind,
|
|
512
|
+
...(evidence.attemptedAt !== undefined ? { attemptedAt: evidence.attemptedAt } : {}),
|
|
513
|
+
reason: evidence.reason,
|
|
514
|
+
},
|
|
515
|
+
guidance: UNVERIFIED_GUIDANCE,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
// ---- persistence -------------------------------------------------------------------------------
|
|
519
|
+
/** Synchronous append — survives a process crash the instant it returns (D-06 § 3.3). Throws. */
|
|
520
|
+
append(receipt) {
|
|
521
|
+
mkdirSync(this.dataDir, { recursive: true, mode: 0o700 });
|
|
522
|
+
const lead = this.needsNewline ? '\n' : '';
|
|
523
|
+
const header = this.journalStarted ? '' : `${JSON.stringify({ v: 1, journal: { generation: randomUUID() } })}\n`;
|
|
524
|
+
appendFileSync(this.journalPath, `${lead}${header}${JSON.stringify(receipt)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
525
|
+
this.journalStarted = true;
|
|
526
|
+
this.needsNewline = false;
|
|
527
|
+
this.linesSinceCompaction += 1;
|
|
528
|
+
}
|
|
529
|
+
/** Append where a failed write must not change the answer; the in-memory receipt still replays. */
|
|
530
|
+
appendBestEffort(receipt) {
|
|
531
|
+
this.receipts.set(receipt.key, receipt);
|
|
532
|
+
try {
|
|
533
|
+
this.append(receipt);
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
this.warnOnce('journal', `[xez] MCP operation journal unwritable (${errorMessage(err)})`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
maybeCompact() {
|
|
540
|
+
if (this.linesSinceCompaction >= this.snapshotEveryLines)
|
|
541
|
+
this.compact();
|
|
542
|
+
}
|
|
543
|
+
load() {
|
|
544
|
+
let journal;
|
|
545
|
+
if (existsSync(this.journalPath)) {
|
|
546
|
+
try {
|
|
547
|
+
journal = readFileSync(this.journalPath);
|
|
548
|
+
}
|
|
549
|
+
catch (err) {
|
|
550
|
+
// An unreadable journal hides receipts we cannot see — treat it as damage, never as empty.
|
|
551
|
+
this.quarantined = 1;
|
|
552
|
+
this.damageDetectedAt = new Date(this.now()).toISOString();
|
|
553
|
+
this.warnOnce('read', `[xez] MCP operation journal unreadable (${errorMessage(err)})`);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (!journal || journal.length === 0)
|
|
558
|
+
return;
|
|
559
|
+
this.journalStarted = true;
|
|
560
|
+
this.needsNewline = journal[journal.length - 1] !== 0x0a;
|
|
561
|
+
const firstNewline = journal.indexOf(0x0a);
|
|
562
|
+
const header = headerSchema.safeParse(safeJson(journal.subarray(0, firstNewline < 0 ? journal.length : firstNewline).toString('utf8')));
|
|
563
|
+
let offset = 0;
|
|
564
|
+
if (header.success) {
|
|
565
|
+
const snapshot = this.readSnapshot();
|
|
566
|
+
if (snapshot &&
|
|
567
|
+
snapshot.generation === header.data.journal.generation &&
|
|
568
|
+
snapshot.offset <= journal.length &&
|
|
569
|
+
(snapshot.offset === 0 || journal[snapshot.offset - 1] === 0x0a)) {
|
|
570
|
+
for (const receipt of snapshot.receipts)
|
|
571
|
+
this.receipts.set(receipt.key, receipt);
|
|
572
|
+
if (snapshot.damage)
|
|
573
|
+
this.noteDamage(snapshot.damage.lines, snapshot.damage.detectedAt);
|
|
574
|
+
offset = snapshot.offset;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const text = journal.subarray(offset).toString('utf8');
|
|
578
|
+
const lines = text.split('\n');
|
|
579
|
+
let scanned = 0;
|
|
580
|
+
let bad = 0;
|
|
581
|
+
for (const [index, line] of lines.entries()) {
|
|
582
|
+
if (line.length === 0)
|
|
583
|
+
continue;
|
|
584
|
+
// The last segment without a trailing newline is a torn write — the realistic crash artefact.
|
|
585
|
+
const torn = index === lines.length - 1;
|
|
586
|
+
scanned += 1;
|
|
587
|
+
const raw = torn ? undefined : safeJson(line);
|
|
588
|
+
const receipt = raw === undefined ? undefined : receiptSchema.safeParse(raw);
|
|
589
|
+
if (receipt?.success) {
|
|
590
|
+
this.receipts.set(receipt.data.key, receipt.data);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (raw !== undefined && headerSchema.safeParse(raw).success)
|
|
594
|
+
continue;
|
|
595
|
+
const damage = raw === undefined ? undefined : damageSchema.safeParse(raw);
|
|
596
|
+
if (damage?.success) {
|
|
597
|
+
this.noteDamage(damage.data.damage.lines, damage.data.damage.detectedAt);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
bad += 1;
|
|
601
|
+
}
|
|
602
|
+
this.linesSinceCompaction = scanned;
|
|
603
|
+
if (bad > 0) {
|
|
604
|
+
this.noteDamage(bad, new Date(this.now()).toISOString());
|
|
605
|
+
this.warnOnce('quarantine', `[xez] MCP operation journal: ${bad} unreadable line(s) quarantined`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
noteDamage(lines, detectedAt) {
|
|
609
|
+
this.quarantined += lines;
|
|
610
|
+
if (this.damageDetectedAt === undefined || detectedAt < this.damageDetectedAt)
|
|
611
|
+
this.damageDetectedAt = detectedAt;
|
|
612
|
+
}
|
|
613
|
+
readSnapshot() {
|
|
614
|
+
try {
|
|
615
|
+
if (!existsSync(this.snapshotPath))
|
|
616
|
+
return undefined;
|
|
617
|
+
const parsed = snapshotSchema.safeParse(JSON.parse(readFileSync(this.snapshotPath, 'utf8')));
|
|
618
|
+
return parsed.success ? parsed.data : undefined;
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return undefined; // a corrupt snapshot is ignored entirely; the journal is the truth
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Retention (D-06 § 8). A receipt is evicted only when it is BOTH older than the age floor AND
|
|
626
|
+
* outside the newest `maxKept` for its project. A dangling intent is never evicted — its
|
|
627
|
+
* `unverified` answer must not decay into "never happened" — and neither is a receipt whose
|
|
628
|
+
* result still exists (§ 8.4). Touches these files and nothing else (§ 12.2).
|
|
629
|
+
*/
|
|
630
|
+
evict() {
|
|
631
|
+
const byProject = new Map();
|
|
632
|
+
for (const receipt of this.receipts.values()) {
|
|
633
|
+
const project = projectOfKey(receipt.key);
|
|
634
|
+
const list = byProject.get(project) ?? [];
|
|
635
|
+
list.push(receipt);
|
|
636
|
+
byProject.set(project, list);
|
|
637
|
+
}
|
|
638
|
+
const cutoff = new Date(this.now() - this.maxAgeMs).toISOString();
|
|
639
|
+
for (const list of byProject.values()) {
|
|
640
|
+
if (list.length <= this.maxKept)
|
|
641
|
+
continue;
|
|
642
|
+
list.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
643
|
+
for (const receipt of list.slice(this.maxKept)) {
|
|
644
|
+
if (receipt.startedAt >= cutoff)
|
|
645
|
+
continue;
|
|
646
|
+
if (receipt.phase !== 'settled')
|
|
647
|
+
continue;
|
|
648
|
+
if (receipt.resultRef && this.opts.isResultLive?.(receipt.resultRef))
|
|
649
|
+
continue;
|
|
650
|
+
this.receipts.delete(receipt.key);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Evict, then rewrite the journal from the folded set and write an offset-anchored snapshot
|
|
656
|
+
* (D-06 § 7.2, § 8.1). The quarantine count survives the rewrite as a damage marker until the
|
|
657
|
+
* age floor has passed since it was detected — a torn line's operation cannot be retried later
|
|
658
|
+
* than that — and then it is compacted out on its own, with no manual step.
|
|
659
|
+
*/
|
|
660
|
+
compact() {
|
|
661
|
+
this.evict();
|
|
662
|
+
if (this.damageDetectedAt !== undefined && this.damageDetectedAt < new Date(this.now() - this.maxAgeMs).toISOString()) {
|
|
663
|
+
this.quarantined = 0;
|
|
664
|
+
this.damageDetectedAt = undefined;
|
|
665
|
+
}
|
|
666
|
+
const generation = randomUUID();
|
|
667
|
+
const damage = this.quarantined > 0 && this.damageDetectedAt !== undefined
|
|
668
|
+
? { lines: this.quarantined, detectedAt: this.damageDetectedAt }
|
|
669
|
+
: undefined;
|
|
670
|
+
const receipts = [...this.receipts.values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
671
|
+
const body = [
|
|
672
|
+
JSON.stringify({ v: 1, journal: { generation } }),
|
|
673
|
+
...(damage ? [JSON.stringify({ v: 1, damage })] : []),
|
|
674
|
+
...receipts.map((receipt) => JSON.stringify(receipt)),
|
|
675
|
+
].join('\n');
|
|
676
|
+
const journal = `${body}\n`;
|
|
677
|
+
try {
|
|
678
|
+
writeAtomic(this.journalPath, journal);
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
// The old journal is intact (the rename is atomic), so nothing is lost — only the shortcut.
|
|
682
|
+
this.warnOnce('compact', `[xez] MCP operation journal compaction failed (${errorMessage(err)})`);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
this.journalStarted = true;
|
|
686
|
+
this.needsNewline = false;
|
|
687
|
+
this.linesSinceCompaction = 0;
|
|
688
|
+
try {
|
|
689
|
+
writeAtomic(this.snapshotPath, JSON.stringify({ v: 1, generation, offset: Buffer.byteLength(journal, 'utf8'), receipts, ...(damage ? { damage } : {}) }));
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
// A failed shortcut never turns a successful mutation into an error (§ 7.5).
|
|
693
|
+
this.warnOnce('snapshot', `[xez] MCP operation snapshot write failed (${errorMessage(err)})`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
warnOnce(topic, message) {
|
|
697
|
+
if (this.warned.has(topic))
|
|
698
|
+
return;
|
|
699
|
+
this.warned.add(topic);
|
|
700
|
+
console.warn(message);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
function safeJson(line) {
|
|
704
|
+
try {
|
|
705
|
+
return JSON.parse(line);
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/** Per-writer tmp path + rename, `0600` — the `config.ts` discipline, not a fixed `.tmp` (D-06 § 13.1). */
|
|
712
|
+
function writeAtomic(path, content) {
|
|
713
|
+
const tmp = atomicTmpPath(path);
|
|
714
|
+
writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
|
|
715
|
+
renameSync(tmp, path);
|
|
716
|
+
try {
|
|
717
|
+
chmodSync(path, 0o600);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// best-effort on filesystems without modes
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
//# sourceMappingURL=operation-receipts.js.map
|