@junghanacs/entwurf 0.12.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/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,1951 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meta-session — 1.0.0 garden-native meta-bridge, step 2: the RECORD AUTHORITY.
|
|
3
|
+
*
|
|
4
|
+
* Backend-agnostic garden layer (#30). A *meta-session* is the bib card for a
|
|
5
|
+
* native backend session (Claude Code / Antigravity / Codex) that has NO pi
|
|
6
|
+
* JSONL of its own: an opaque pointer record that makes the native session a
|
|
7
|
+
* garden citizen — addressable + wakeable by a garden id — WITHOUT pretending pi
|
|
8
|
+
* owns its transcript (Hard Rule #8: reference the backend transcript, never
|
|
9
|
+
* hydrate or replay it).
|
|
10
|
+
*
|
|
11
|
+
* Two layers, clearly sectioned:
|
|
12
|
+
* 1. RECORD functions + types (mint / serialize / parse / scanByNativeId /
|
|
13
|
+
* decideUpsert / read-receipt mutators), the backend-agnostic authority.
|
|
14
|
+
* Pure beyond an injected `now`, with ONE exception since 3D-3: mint/parse
|
|
15
|
+
* read backend capability (wakeMode/deliveryLevel) from the packaged registry
|
|
16
|
+
* via a cached fs read (loadMetaCapabilityRegistry) — see that seam below.
|
|
17
|
+
* 2. The thin FS-BOUND STORE (step 3): `upsertMetaSession` wraps the pure core
|
|
18
|
+
* (readdir → `scanByNativeId` → `decideUpsert` → atomic write) with the real
|
|
19
|
+
* filesystem. It lives in this module (not a sibling `*-store.ts`) on purpose:
|
|
20
|
+
* the typecheck fence forbids a root-config lib importing another `.ts` lib
|
|
21
|
+
* via a `.ts` specifier (tsc-emit) while the same `.js` specifier is
|
|
22
|
+
* unresolvable under `node --experimental-strip-types`, so a separate store
|
|
23
|
+
* file could not be unit-tested by the deterministic strip-types gate. Only
|
|
24
|
+
* node builtins are added here, so `check-meta-session` stays strip-types
|
|
25
|
+
* clean. The hook deploy + the thin CLI/argv shell that invokes this is
|
|
26
|
+
* step 4 (its stdin contract couples to the Claude `SessionStart` payload).
|
|
27
|
+
*
|
|
28
|
+
* Cutting the record/seam FIRST is deliberate ("record authority FIRST, hook
|
|
29
|
+
* LAST"): the schema and the lookup authority are backend-agnostic, so the
|
|
30
|
+
* per-backend adapter seam gets cut here, before any "hook = Claude Code"
|
|
31
|
+
* assumption can ossify.
|
|
32
|
+
*
|
|
33
|
+
* Authority rules imported from the 0.9.0 substrate and #30 refinements:
|
|
34
|
+
* - garden id = `generateSessionId` (the single SSOT grammar), minted at the
|
|
35
|
+
* session's true birth. Reused, never re-derived.
|
|
36
|
+
* - lookup authority = SCAN the record bodies by top-level `native_session_id`
|
|
37
|
+
* (see scanByNativeId), symmetric with 0.9.0 `findSessionFileById`. Any
|
|
38
|
+
* native→garden index is an OPTIONAL derived cache, never the source of
|
|
39
|
+
* truth — "needs a DB" is the denote-instinct tripwire.
|
|
40
|
+
* - create-vs-attach keys on RECORD EXISTENCE, not the backend `source` field
|
|
41
|
+
* (decideUpsert). Idempotent: duplicate hook fires / same-id re-entry are
|
|
42
|
+
* absorbed. The CLI is named `upsert` so no one re-introduces `source`
|
|
43
|
+
* branching.
|
|
44
|
+
* - read-receipt is PRE-DRILLED into the schema now (bbot review #4). The
|
|
45
|
+
* mailbox/outbox is post-MVP, but retrofitting the receipt field later would
|
|
46
|
+
* touch the schema twice. The "last 1 cm" (did the body reach model-visible
|
|
47
|
+
* context?) is kept honest as per-peer metadata, never abstracted away.
|
|
48
|
+
*
|
|
49
|
+
* Crash, don't warn: every malformed record / bad id / bad backend throws
|
|
50
|
+
* `MetaRecordError`. A broken meta-record must surface as a broken meta-record.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { execFileSync } from "node:child_process";
|
|
54
|
+
import * as crypto from "node:crypto";
|
|
55
|
+
import * as fs from "node:fs";
|
|
56
|
+
import * as os from "node:os";
|
|
57
|
+
import * as path from "node:path";
|
|
58
|
+
import { generateSessionId, SESSION_ID_RE } from "./session-id.js";
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Errors
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** A meta-record is malformed, or an input violates the record contract. */
|
|
65
|
+
export class MetaRecordError extends Error {
|
|
66
|
+
constructor(message: string) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "MetaRecordError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Schema
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Bump only on a breaking record-shape change; parse refuses other versions. */
|
|
77
|
+
export const META_SCHEMA_VERSION = 1 as const;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The three native meta-bridge backends, declared from the start so the
|
|
81
|
+
* per-backend adapter seam is forced (a different native layout each — that
|
|
82
|
+
* difference is the whole reason for a thin adapter). Discriminator field on
|
|
83
|
+
* every record.
|
|
84
|
+
*/
|
|
85
|
+
export const META_BACKENDS = ["claude-code", "antigravity", "codex"] as const;
|
|
86
|
+
export type MetaBackend = (typeof META_BACKENDS)[number];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How the body reaches model-visible context — the honest "last 1 cm". Claude's
|
|
90
|
+
* doorbell wakes the model but the body is SELF-FETCHED (the model must
|
|
91
|
+
* voluntarily call its inbox-read MCP tool); agy/codex DIRECT-INJECT the body
|
|
92
|
+
* into the turn. This is exposed as peer metadata, never abstracted, so "I sent
|
|
93
|
+
* it, why didn't it read?" never becomes a debugging hole.
|
|
94
|
+
*/
|
|
95
|
+
export type WakeMode = "self-fetch" | "direct-inject";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Static, backend-derived honesty metadata (the data half of the adapter seam).
|
|
99
|
+
* `wakeMode` + `deliveryLevel` ride onto each record at mint so the sender
|
|
100
|
+
* contract can stay uniform on address/queue while being honest on HOW delivery
|
|
101
|
+
* lands. `deliveryLevel` is a DELIVERY.md D-coordinate (a capability hint, not a
|
|
102
|
+
* guarantee). `nativeIdLabel` documents what the join key actually is per
|
|
103
|
+
* backend (Claude sessionId / agy conversationId / codex threadId) — naming, not
|
|
104
|
+
* behavior; the behavioral half (where sessions live, how to read liveness, hook
|
|
105
|
+
* deploy unit) lands with the step-4 adapters.
|
|
106
|
+
*/
|
|
107
|
+
export interface MetaBackendDescriptor {
|
|
108
|
+
backend: MetaBackend;
|
|
109
|
+
wakeMode: WakeMode;
|
|
110
|
+
deliveryLevel: string;
|
|
111
|
+
nativeIdLabel: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const META_BACKEND_DESCRIPTORS: Record<MetaBackend, MetaBackendDescriptor> = {
|
|
115
|
+
"claude-code": {
|
|
116
|
+
backend: "claude-code",
|
|
117
|
+
wakeMode: "self-fetch",
|
|
118
|
+
deliveryLevel: "D6",
|
|
119
|
+
nativeIdLabel: "sessionId",
|
|
120
|
+
},
|
|
121
|
+
antigravity: {
|
|
122
|
+
backend: "antigravity",
|
|
123
|
+
wakeMode: "direct-inject",
|
|
124
|
+
deliveryLevel: "D6",
|
|
125
|
+
nativeIdLabel: "conversationId",
|
|
126
|
+
},
|
|
127
|
+
codex: {
|
|
128
|
+
backend: "codex",
|
|
129
|
+
wakeMode: "direct-inject",
|
|
130
|
+
deliveryLevel: "D6",
|
|
131
|
+
nativeIdLabel: "threadId",
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The read-receipt aspect, PRE-DRILLED (bbot review #4). The mailbox/outbox is
|
|
137
|
+
* post-MVP — these timestamps stay null until that path lands — but the slot is
|
|
138
|
+
* here so adding it later does not touch the schema twice.
|
|
139
|
+
* - lastEnqueuedAt : a sender wrote a message body to this peer's mailbox.
|
|
140
|
+
* - lastDeliveredAt: the doorbell rang / the body was injected ("`.delivered`"
|
|
141
|
+
* marker). For Claude self-fetch this means "doorbell rang", NOT "model read".
|
|
142
|
+
* - lastReadAt : the inbox-read MCP call — THIS is the real read-receipt
|
|
143
|
+
* (makes Claude's D7 observable). For direct-inject backends delivered==read.
|
|
144
|
+
*/
|
|
145
|
+
export interface MetaDelivery {
|
|
146
|
+
wakeMode: WakeMode;
|
|
147
|
+
deliveryLevel: string;
|
|
148
|
+
lastEnqueuedAt: string | null;
|
|
149
|
+
lastDeliveredAt: string | null;
|
|
150
|
+
lastReadAt: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The opaque pointer record. Body is SSOT; the on-disk filename
|
|
155
|
+
* (`<garden_id>.meta.json`) is only a denote-sortable surface (garden_id leads
|
|
156
|
+
* with the birth timestamp). NEVER parse the filename for authority.
|
|
157
|
+
*/
|
|
158
|
+
export interface MetaRecord {
|
|
159
|
+
schemaVersion: typeof META_SCHEMA_VERSION;
|
|
160
|
+
gardenId: string;
|
|
161
|
+
backend: MetaBackend;
|
|
162
|
+
nativeSessionId: string;
|
|
163
|
+
transcriptPath: string;
|
|
164
|
+
cwd: string;
|
|
165
|
+
createdAt: string;
|
|
166
|
+
lastSeen: string;
|
|
167
|
+
delivery: MetaDelivery;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Fields the caller supplies; garden id + timestamps + delivery are derived. */
|
|
171
|
+
export interface MetaMintInput {
|
|
172
|
+
backend: MetaBackend;
|
|
173
|
+
nativeSessionId: string;
|
|
174
|
+
transcriptPath: string;
|
|
175
|
+
cwd: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Validation helpers (crash, don't warn)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function requireNonEmptyString(value: unknown, field: string): string {
|
|
183
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
184
|
+
throw new MetaRecordError(`meta-record field "${field}" must be a non-empty string (got ${describe(value)}).`);
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function requireBackend(value: unknown): MetaBackend {
|
|
190
|
+
if (typeof value !== "string" || !META_BACKENDS.includes(value as MetaBackend)) {
|
|
191
|
+
throw new MetaRecordError(
|
|
192
|
+
`meta-record "backend" must be one of ${META_BACKENDS.join(" | ")} (got ${describe(value)}).`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return value as MetaBackend;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function requireGardenId(value: unknown): string {
|
|
199
|
+
const id = requireNonEmptyString(value, "gardenId");
|
|
200
|
+
if (!SESSION_ID_RE.test(id)) {
|
|
201
|
+
throw new MetaRecordError(`meta-record "gardenId" must match YYYYMMDDTHHMMSS-[0-9a-f]{6} (got "${id}").`);
|
|
202
|
+
}
|
|
203
|
+
return id;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function requireNullableString(value: unknown, field: string): string | null {
|
|
207
|
+
if (value === null) return null;
|
|
208
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
209
|
+
throw new MetaRecordError(
|
|
210
|
+
`meta-record field "${field}" must be a non-empty string or null (got ${describe(value)}).`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function describe(value: unknown): string {
|
|
217
|
+
if (value === null) return "null";
|
|
218
|
+
if (typeof value === "string") return `string ${JSON.stringify(value)}`;
|
|
219
|
+
return `${typeof value}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isoNow(now: Date): string {
|
|
223
|
+
return now.toISOString();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Record functions (pure beyond an injected `now`, except mint/parse read the
|
|
228
|
+
// packaged capability registry via the cached metaCapabilityFor seam — 3D-3)
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Mint a brand-new meta-record at the session's true birth. Generates the garden
|
|
233
|
+
* id from the SSOT grammar, stamps createdAt == lastSeen, and seeds the
|
|
234
|
+
* delivery/read-receipt slot from the backend descriptor (timestamps null).
|
|
235
|
+
*/
|
|
236
|
+
export function mintMetaRecord(input: MetaMintInput, now: Date = new Date()): MetaRecord {
|
|
237
|
+
const backend = requireBackend(input.backend);
|
|
238
|
+
// 3D-3: backend honesty metadata is sourced from the capability registry, not
|
|
239
|
+
// META_BACKEND_DESCRIPTORS (which now survives only as the drift-guard reference).
|
|
240
|
+
const capability = metaCapabilityFor(backend);
|
|
241
|
+
const ts = isoNow(now);
|
|
242
|
+
return {
|
|
243
|
+
schemaVersion: META_SCHEMA_VERSION,
|
|
244
|
+
gardenId: generateSessionId(now),
|
|
245
|
+
backend,
|
|
246
|
+
nativeSessionId: requireNonEmptyString(input.nativeSessionId, "nativeSessionId"),
|
|
247
|
+
transcriptPath: requireNonEmptyString(input.transcriptPath, "transcriptPath"),
|
|
248
|
+
cwd: requireNonEmptyString(input.cwd, "cwd"),
|
|
249
|
+
createdAt: ts,
|
|
250
|
+
lastSeen: ts,
|
|
251
|
+
delivery: {
|
|
252
|
+
wakeMode: capability.wakeMode,
|
|
253
|
+
deliveryLevel: capability.deliveryLevel,
|
|
254
|
+
lastEnqueuedAt: null,
|
|
255
|
+
lastDeliveredAt: null,
|
|
256
|
+
lastReadAt: null,
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Canonical serialization: stable key order (object built in order), 2-space
|
|
263
|
+
* indent, trailing newline. Deterministic — the same record always serializes
|
|
264
|
+
* byte-identically, so a temp-dir test can assert round-trip stability.
|
|
265
|
+
*/
|
|
266
|
+
export function serializeMetaRecord(record: MetaRecord): string {
|
|
267
|
+
const ordered = {
|
|
268
|
+
schemaVersion: record.schemaVersion,
|
|
269
|
+
gardenId: record.gardenId,
|
|
270
|
+
backend: record.backend,
|
|
271
|
+
nativeSessionId: record.nativeSessionId,
|
|
272
|
+
transcriptPath: record.transcriptPath,
|
|
273
|
+
cwd: record.cwd,
|
|
274
|
+
createdAt: record.createdAt,
|
|
275
|
+
lastSeen: record.lastSeen,
|
|
276
|
+
delivery: {
|
|
277
|
+
wakeMode: record.delivery.wakeMode,
|
|
278
|
+
deliveryLevel: record.delivery.deliveryLevel,
|
|
279
|
+
lastEnqueuedAt: record.delivery.lastEnqueuedAt,
|
|
280
|
+
lastDeliveredAt: record.delivery.lastDeliveredAt,
|
|
281
|
+
lastReadAt: record.delivery.lastReadAt,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Parse + fully validate untrusted JSON text into a MetaRecord. Throws on any drift. */
|
|
288
|
+
export function parseMetaRecord(json: string): MetaRecord {
|
|
289
|
+
let raw: unknown;
|
|
290
|
+
try {
|
|
291
|
+
raw = JSON.parse(json);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
throw new MetaRecordError(`meta-record is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
294
|
+
}
|
|
295
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
296
|
+
throw new MetaRecordError(`meta-record must be a JSON object (got ${describe(raw)}).`);
|
|
297
|
+
}
|
|
298
|
+
const obj = raw as Record<string, unknown>;
|
|
299
|
+
if (obj.schemaVersion !== META_SCHEMA_VERSION) {
|
|
300
|
+
throw new MetaRecordError(
|
|
301
|
+
`meta-record "schemaVersion" must be ${META_SCHEMA_VERSION} (got ${describe(obj.schemaVersion)}).`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const delivery = obj.delivery;
|
|
305
|
+
if (typeof delivery !== "object" || delivery === null || Array.isArray(delivery)) {
|
|
306
|
+
throw new MetaRecordError(`meta-record "delivery" must be an object (got ${describe(delivery)}).`);
|
|
307
|
+
}
|
|
308
|
+
const d = delivery as Record<string, unknown>;
|
|
309
|
+
const backend = requireBackend(obj.backend);
|
|
310
|
+
const wakeMode = requireNonEmptyString(d.wakeMode, "delivery.wakeMode");
|
|
311
|
+
if (wakeMode !== "self-fetch" && wakeMode !== "direct-inject") {
|
|
312
|
+
throw new MetaRecordError(
|
|
313
|
+
`meta-record "delivery.wakeMode" must be self-fetch | direct-inject (got "${wakeMode}").`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
// wakeMode is backend-DETERMINED (Claude doorbell = self-fetch; agy/codex =
|
|
317
|
+
// direct-inject). A record whose stored wakeMode contradicts its backend is
|
|
318
|
+
// corrupt — a Claude record claiming direct-inject would silently mis-route
|
|
319
|
+
// the "last 1 cm" delivery contract. Refuse it. 3D-3: the canonical is sourced
|
|
320
|
+
// from the capability registry, not META_BACKEND_DESCRIPTORS.
|
|
321
|
+
const canonicalWakeMode = metaCapabilityFor(backend).wakeMode;
|
|
322
|
+
if (wakeMode !== canonicalWakeMode) {
|
|
323
|
+
throw new MetaRecordError(
|
|
324
|
+
`meta-record "delivery.wakeMode" (${wakeMode}) contradicts backend "${backend}" ` +
|
|
325
|
+
`(canonical ${canonicalWakeMode}). Delivery mode is backend-determined; this record is corrupt.`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
schemaVersion: META_SCHEMA_VERSION,
|
|
330
|
+
gardenId: requireGardenId(obj.gardenId),
|
|
331
|
+
backend,
|
|
332
|
+
nativeSessionId: requireNonEmptyString(obj.nativeSessionId, "nativeSessionId"),
|
|
333
|
+
transcriptPath: requireNonEmptyString(obj.transcriptPath, "transcriptPath"),
|
|
334
|
+
cwd: requireNonEmptyString(obj.cwd, "cwd"),
|
|
335
|
+
createdAt: requireNonEmptyString(obj.createdAt, "createdAt"),
|
|
336
|
+
lastSeen: requireNonEmptyString(obj.lastSeen, "lastSeen"),
|
|
337
|
+
delivery: {
|
|
338
|
+
wakeMode,
|
|
339
|
+
deliveryLevel: requireNonEmptyString(d.deliveryLevel, "delivery.deliveryLevel"),
|
|
340
|
+
lastEnqueuedAt: requireNullableString(d.lastEnqueuedAt, "delivery.lastEnqueuedAt"),
|
|
341
|
+
lastDeliveredAt: requireNullableString(d.lastDeliveredAt, "delivery.lastDeliveredAt"),
|
|
342
|
+
lastReadAt: requireNullableString(d.lastReadAt, "delivery.lastReadAt"),
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// meta-record v2 — identity-only shape (0.11 Stage 0 step 3A)
|
|
349
|
+
//
|
|
350
|
+
// v2 strips the delivery/read-receipt aspect OUT of the record (it moves to a
|
|
351
|
+
// separate mailbox state file in step 3B) and keeps only IDENTITY: who this
|
|
352
|
+
// citizen is, never its delivery bookkeeping. The deltas vs v1 (verified
|
|
353
|
+
// against the frozen ledger in NEXT.md):
|
|
354
|
+
// - backend gains `pi` (the 4th meta backend — pi sessions become citizens)
|
|
355
|
+
// - transcriptPath required → nullable (pi birth may not know it yet)
|
|
356
|
+
// - new nullable identity fields: model, parentGardenId, isEntwurf
|
|
357
|
+
// - lastSeen → recordUpdatedAt (a record touch time, NOT liveness)
|
|
358
|
+
// - delivery{} removed entirely
|
|
359
|
+
//
|
|
360
|
+
// This block is READER + NORMALIZER ONLY. There is deliberately NO v2 writer /
|
|
361
|
+
// serializer / disk upsert here yet: step 3A's gate is "synthetic v1 fixture →
|
|
362
|
+
// normalized v2 identity golden GREEN", and 3A must not introduce a v2 writer
|
|
363
|
+
// before that golden + its GPT review (NEXT.md 끊을 지점 ①).
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
/** Bump only on a breaking v2 identity-shape change; the v2 parser refuses other versions. */
|
|
367
|
+
export const META_SCHEMA_VERSION_V2 = 2 as const;
|
|
368
|
+
|
|
369
|
+
/** v2 backends = the three v1 backends + `pi` (pi joins as the 4th meta citizen). */
|
|
370
|
+
export const META_BACKENDS_V2 = ["claude-code", "antigravity", "codex", "pi"] as const;
|
|
371
|
+
export type MetaBackendV2 = (typeof META_BACKENDS_V2)[number];
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The v2 identity-only record. Field order mirrors the frozen ledger's jsonc so
|
|
375
|
+
* a future serializer stays byte-stable. No delivery aspect — that is mailbox
|
|
376
|
+
* state (step 3B), referenced by gardenId, never embedded in identity.
|
|
377
|
+
*/
|
|
378
|
+
export interface MetaIdentity {
|
|
379
|
+
schemaVersion: typeof META_SCHEMA_VERSION_V2;
|
|
380
|
+
gardenId: string;
|
|
381
|
+
backend: MetaBackendV2;
|
|
382
|
+
nativeSessionId: string;
|
|
383
|
+
cwd: string;
|
|
384
|
+
model: string | null;
|
|
385
|
+
transcriptPath: string | null;
|
|
386
|
+
parentGardenId: string | null;
|
|
387
|
+
isEntwurf: boolean;
|
|
388
|
+
createdAt: string;
|
|
389
|
+
recordUpdatedAt: string;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function requireBackendV2(value: unknown): MetaBackendV2 {
|
|
393
|
+
if (typeof value !== "string" || !META_BACKENDS_V2.includes(value as MetaBackendV2)) {
|
|
394
|
+
throw new MetaRecordError(
|
|
395
|
+
`meta-record "backend" must be one of ${META_BACKENDS_V2.join(" | ")} (got ${describe(value)}).`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
return value as MetaBackendV2;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function requireBoolean(value: unknown, field: string): boolean {
|
|
402
|
+
if (typeof value !== "boolean") {
|
|
403
|
+
throw new MetaRecordError(`meta-record field "${field}" must be a boolean (got ${describe(value)}).`);
|
|
404
|
+
}
|
|
405
|
+
return value;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function requireNullableGardenId(value: unknown, field: string): string | null {
|
|
409
|
+
if (value === null) return null;
|
|
410
|
+
const id = requireNonEmptyString(value, field);
|
|
411
|
+
if (!SESSION_ID_RE.test(id)) {
|
|
412
|
+
throw new MetaRecordError(
|
|
413
|
+
`meta-record "${field}" must be null or match YYYYMMDDTHHMMSS-[0-9a-f]{6} (got "${id}").`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return id;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Explicit v1 name for the dual-read pair. `parseMetaRecord` predates the v2
|
|
421
|
+
* split and stays the canonical v1 parser (existing callers untouched); this
|
|
422
|
+
* alias makes the V1/V2 symmetry legible at call sites.
|
|
423
|
+
*/
|
|
424
|
+
export const parseMetaRecordV1 = parseMetaRecord;
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The EXACT key set a v2 identity record may carry. v2 is a fresh schema, so the
|
|
428
|
+
* parser is strict: any key outside this set — including stale v1 fields like
|
|
429
|
+
* `delivery` or `lastSeen` — is a half-migrated / corrupt record and must
|
|
430
|
+
* fail-fast, never be silently normalized away. Frozen against the ledger jsonc.
|
|
431
|
+
*/
|
|
432
|
+
const META_IDENTITY_V2_KEYS: readonly string[] = [
|
|
433
|
+
"schemaVersion",
|
|
434
|
+
"gardenId",
|
|
435
|
+
"backend",
|
|
436
|
+
"nativeSessionId",
|
|
437
|
+
"cwd",
|
|
438
|
+
"model",
|
|
439
|
+
"transcriptPath",
|
|
440
|
+
"parentGardenId",
|
|
441
|
+
"isEntwurf",
|
|
442
|
+
"createdAt",
|
|
443
|
+
"recordUpdatedAt",
|
|
444
|
+
];
|
|
445
|
+
|
|
446
|
+
/** Parse + fully validate untrusted JSON into a v2 MetaIdentity. Throws on any drift. */
|
|
447
|
+
export function parseMetaRecordV2(json: string): MetaIdentity {
|
|
448
|
+
let raw: unknown;
|
|
449
|
+
try {
|
|
450
|
+
raw = JSON.parse(json);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
throw new MetaRecordError(`meta-record is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
453
|
+
}
|
|
454
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
455
|
+
throw new MetaRecordError(`meta-record must be a JSON object (got ${describe(raw)}).`);
|
|
456
|
+
}
|
|
457
|
+
const obj = raw as Record<string, unknown>;
|
|
458
|
+
if (obj.schemaVersion !== META_SCHEMA_VERSION_V2) {
|
|
459
|
+
throw new MetaRecordError(
|
|
460
|
+
`meta-record "schemaVersion" must be ${META_SCHEMA_VERSION_V2} (got ${describe(obj.schemaVersion)}).`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
// Strict keyset: reject stale v1 fields (delivery/lastSeen) and any unknown
|
|
464
|
+
// key. A v2 record carrying v1 leftovers is half-migrated/corrupt — surface
|
|
465
|
+
// it, do not silently drop it during normalize.
|
|
466
|
+
const stray = Object.keys(obj).filter((k) => !META_IDENTITY_V2_KEYS.includes(k));
|
|
467
|
+
if (stray.length > 0) {
|
|
468
|
+
throw new MetaRecordError(
|
|
469
|
+
`v2 meta-record carries unexpected key(s) ${stray.map((k) => `"${k}"`).join(", ")} ` +
|
|
470
|
+
`(allowed: ${META_IDENTITY_V2_KEYS.join(", ")}). Stale v1 fields (delivery/lastSeen) or unknown keys are rejected.`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
schemaVersion: META_SCHEMA_VERSION_V2,
|
|
475
|
+
gardenId: requireGardenId(obj.gardenId),
|
|
476
|
+
backend: requireBackendV2(obj.backend),
|
|
477
|
+
nativeSessionId: requireNonEmptyString(obj.nativeSessionId, "nativeSessionId"),
|
|
478
|
+
cwd: requireNonEmptyString(obj.cwd, "cwd"),
|
|
479
|
+
model: requireNullableString(obj.model, "model"),
|
|
480
|
+
transcriptPath: requireNullableString(obj.transcriptPath, "transcriptPath"),
|
|
481
|
+
parentGardenId: requireNullableGardenId(obj.parentGardenId, "parentGardenId"),
|
|
482
|
+
isEntwurf: requireBoolean(obj.isEntwurf, "isEntwurf"),
|
|
483
|
+
createdAt: requireNonEmptyString(obj.createdAt, "createdAt"),
|
|
484
|
+
recordUpdatedAt: requireNonEmptyString(obj.recordUpdatedAt, "recordUpdatedAt"),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Lazy-normalize a parsed v1 OR v2 record into the v2 identity shape. The dual-
|
|
490
|
+
* read seam: consumers read either disk version and normalize to ONE identity
|
|
491
|
+
* type. Discriminates on `schemaVersion` (TS narrows the union):
|
|
492
|
+
* - v1: lastSeen → recordUpdatedAt, delivery dropped, model/parentGardenId
|
|
493
|
+
* default null, isEntwurf default false, transcriptPath carried (v1 always
|
|
494
|
+
* has one).
|
|
495
|
+
* - v2: already identity — returned as a fresh, key-stable copy.
|
|
496
|
+
* v1 identity is LOSSLESS through this (the golden gate proves it); the only v1
|
|
497
|
+
* data not carried is delivery, which is intentionally out of identity.
|
|
498
|
+
*/
|
|
499
|
+
export function normalizeMetaIdentity(record: MetaRecord | MetaIdentity): MetaIdentity {
|
|
500
|
+
if (record.schemaVersion === META_SCHEMA_VERSION_V2) {
|
|
501
|
+
return {
|
|
502
|
+
schemaVersion: META_SCHEMA_VERSION_V2,
|
|
503
|
+
gardenId: record.gardenId,
|
|
504
|
+
backend: record.backend,
|
|
505
|
+
nativeSessionId: record.nativeSessionId,
|
|
506
|
+
cwd: record.cwd,
|
|
507
|
+
model: record.model,
|
|
508
|
+
transcriptPath: record.transcriptPath,
|
|
509
|
+
parentGardenId: record.parentGardenId,
|
|
510
|
+
isEntwurf: record.isEntwurf,
|
|
511
|
+
createdAt: record.createdAt,
|
|
512
|
+
recordUpdatedAt: record.recordUpdatedAt,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
schemaVersion: META_SCHEMA_VERSION_V2,
|
|
517
|
+
gardenId: record.gardenId,
|
|
518
|
+
backend: record.backend,
|
|
519
|
+
nativeSessionId: record.nativeSessionId,
|
|
520
|
+
cwd: record.cwd,
|
|
521
|
+
model: null,
|
|
522
|
+
transcriptPath: record.transcriptPath,
|
|
523
|
+
parentGardenId: null,
|
|
524
|
+
isEntwurf: false,
|
|
525
|
+
createdAt: record.createdAt,
|
|
526
|
+
recordUpdatedAt: record.lastSeen,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// v2 write shape + dual-read dispatcher (0.11 Stage 0 step 3D-1)
|
|
532
|
+
//
|
|
533
|
+
// Pure functions only: the canonical v2 serializer and the version-dispatching
|
|
534
|
+
// reader. NO fs upsert, NO live readMetaInbox/enqueueMetaMessage change, NO
|
|
535
|
+
// record.delivery removal — those are 3D-2/3/4. This step just makes "write a v2
|
|
536
|
+
// identity" and "read any version into an identity" exist + gated, so 3D-4 can
|
|
537
|
+
// wire the FS upsert onto a proven writer.
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Canonical serialization of a v2 identity: stable key order (the frozen ledger
|
|
542
|
+
* jsonc order), 2-space indent, trailing newline. Deterministic — re-serializing
|
|
543
|
+
* the same identity is byte-identical, and the output round-trips through
|
|
544
|
+
* parseMetaRecordV2. This is the v2 WRITE shape; the FS upsert that uses it is
|
|
545
|
+
* step 3D-4, not here.
|
|
546
|
+
*/
|
|
547
|
+
export function serializeMetaIdentity(identity: MetaIdentity): string {
|
|
548
|
+
const ordered = {
|
|
549
|
+
schemaVersion: identity.schemaVersion,
|
|
550
|
+
gardenId: identity.gardenId,
|
|
551
|
+
backend: identity.backend,
|
|
552
|
+
nativeSessionId: identity.nativeSessionId,
|
|
553
|
+
cwd: identity.cwd,
|
|
554
|
+
model: identity.model,
|
|
555
|
+
transcriptPath: identity.transcriptPath,
|
|
556
|
+
parentGardenId: identity.parentGardenId,
|
|
557
|
+
isEntwurf: identity.isEntwurf,
|
|
558
|
+
createdAt: identity.createdAt,
|
|
559
|
+
recordUpdatedAt: identity.recordUpdatedAt,
|
|
560
|
+
};
|
|
561
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Fields a v2 caller supplies; garden id + timestamps are derived. The nullable
|
|
566
|
+
* identity axes (model/transcriptPath/parentGardenId) are OPTIONAL at the input
|
|
567
|
+
* boundary so attach can distinguish three intents (3D-4 G5): `undefined` = keep
|
|
568
|
+
* the existing value, `null` = explicit unknown/clear, a string = set/refresh.
|
|
569
|
+
* mint (create) has no existing value, so undefined collapses to null.
|
|
570
|
+
*/
|
|
571
|
+
export interface MetaIdentityMintInput {
|
|
572
|
+
backend: MetaBackendV2;
|
|
573
|
+
nativeSessionId: string;
|
|
574
|
+
cwd: string;
|
|
575
|
+
model?: string | null;
|
|
576
|
+
transcriptPath?: string | null;
|
|
577
|
+
parentGardenId?: string | null;
|
|
578
|
+
isEntwurf?: boolean;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Mint a brand-new v2 identity at the session's true birth (3D-4). The v2 analog
|
|
583
|
+
* of mintMetaRecord — generates the garden id, stamps createdAt == recordUpdatedAt,
|
|
584
|
+
* and carries identity only (no delivery; the receipt lives in mailbox state).
|
|
585
|
+
* Omitted nullable axes default to null / isEntwurf false.
|
|
586
|
+
*/
|
|
587
|
+
export function mintMetaIdentity(input: MetaIdentityMintInput, now: Date = new Date()): MetaIdentity {
|
|
588
|
+
const backend = requireBackendV2(input.backend);
|
|
589
|
+
const ts = isoNow(now);
|
|
590
|
+
return {
|
|
591
|
+
schemaVersion: META_SCHEMA_VERSION_V2,
|
|
592
|
+
gardenId: generateSessionId(now),
|
|
593
|
+
backend,
|
|
594
|
+
nativeSessionId: requireNonEmptyString(input.nativeSessionId, "nativeSessionId"),
|
|
595
|
+
cwd: requireNonEmptyString(input.cwd, "cwd"),
|
|
596
|
+
model: requireNullableString(input.model ?? null, "model"),
|
|
597
|
+
transcriptPath: requireNullableString(input.transcriptPath ?? null, "transcriptPath"),
|
|
598
|
+
parentGardenId: requireNullableGardenId(input.parentGardenId ?? null, "parentGardenId"),
|
|
599
|
+
isEntwurf: input.isEntwurf === undefined ? false : requireBoolean(input.isEntwurf, "isEntwurf"),
|
|
600
|
+
createdAt: ts,
|
|
601
|
+
recordUpdatedAt: ts,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Dual-read dispatcher: peek schemaVersion on untrusted JSON and route to the
|
|
607
|
+
* matching strict parser (v1 record or v2 identity). The lazy-normalize seam — a
|
|
608
|
+
* consumer reads either on-disk version through ONE call. Returns the parsed
|
|
609
|
+
* record in its OWN shape (v1 keeps delivery; v2 is identity); compose with
|
|
610
|
+
* normalizeMetaIdentity, or use parseMetaIdentity, to collapse to identity.
|
|
611
|
+
*/
|
|
612
|
+
export function parseMetaRecordAny(json: string): MetaRecord | MetaIdentity {
|
|
613
|
+
let raw: unknown;
|
|
614
|
+
try {
|
|
615
|
+
raw = JSON.parse(json);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
throw new MetaRecordError(`meta-record is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
618
|
+
}
|
|
619
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
620
|
+
throw new MetaRecordError(`meta-record must be a JSON object (got ${describe(raw)}).`);
|
|
621
|
+
}
|
|
622
|
+
const version = (raw as Record<string, unknown>).schemaVersion;
|
|
623
|
+
if (version === META_SCHEMA_VERSION) return parseMetaRecordV1(json);
|
|
624
|
+
if (version === META_SCHEMA_VERSION_V2) return parseMetaRecordV2(json);
|
|
625
|
+
throw new MetaRecordError(
|
|
626
|
+
`meta-record "schemaVersion" must be ${META_SCHEMA_VERSION} or ${META_SCHEMA_VERSION_V2} (got ${describe(version)}).`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Dual-read straight to a normalized v2 identity (parse any version, normalize). */
|
|
631
|
+
export function parseMetaIdentity(json: string): MetaIdentity {
|
|
632
|
+
return normalizeMetaIdentity(parseMetaRecordAny(json));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// capability source — backend capability registry (0.11 Stage 0 step 3C)
|
|
637
|
+
//
|
|
638
|
+
// v2 identity (step 3A) drops the backend honesty metadata (wakeMode /
|
|
639
|
+
// deliveryLevel / nativeIdLabel) out of the per-session record: it is NOT per
|
|
640
|
+
// session, it is per BACKEND. Its new home is a registry data file
|
|
641
|
+
// `pi/entwurf-capabilities.json` (frozen decision 1 — a registry FILE, sibling
|
|
642
|
+
// concern to the launch-allowlist `entwurf-targets.json`). "이 시민은 self-fetch
|
|
643
|
+
// 인가 / pi 는 어떻게 깨우나" is answered by capability, not by identity.
|
|
644
|
+
//
|
|
645
|
+
// This block is the SCHEMA + PARSER + path resolver. As of 3C it did NOT re-wire
|
|
646
|
+
// the live consumers (`META_BACKEND_DESCRIPTORS` was the authority mint/parse read).
|
|
647
|
+
// 3D-3 then cut mint/parse over to this registry via the `metaCapabilityFor` seam
|
|
648
|
+
// (defined below `metaCapabilitiesFilePath`): the registry is now the LIVE source of
|
|
649
|
+
// wakeMode/deliveryLevel, and `META_BACKEND_DESCRIPTORS` survives only as the
|
|
650
|
+
// drift-guard reference. Removing wakeMode from the record itself lands in step 3D-4.
|
|
651
|
+
// The 3C gate (check-entwurf-capabilities) still asserts the JSON AGREES with the
|
|
652
|
+
// const for the three existing backends (the drift guard) and COVERS exactly
|
|
653
|
+
// META_BACKENDS_V2 (pi included).
|
|
654
|
+
//
|
|
655
|
+
// pi's wakeMode = direct-inject (NOT self-fetch): pi's live wake path is the
|
|
656
|
+
// entwurf-control socket — `pi.sendMessage(... triggerTurn ...)` injects the
|
|
657
|
+
// body straight into the model-visible turn, which is direct-inject by the
|
|
658
|
+
// WakeMode definition (the last-1cm: who puts the body in front of the model).
|
|
659
|
+
// self-fetch is Claude's mailbox path (the model must call its inbox-read MCP).
|
|
660
|
+
// pi's dormant→resume→mailbox path is self-fetch-shaped, so pi is really
|
|
661
|
+
// BIMODAL; a single wakeMode field cannot express both. Splitting it
|
|
662
|
+
// (mailboxWakeMode vs controlSocketWakeMode) is out of 3C scope — for now the
|
|
663
|
+
// single field reports pi's primary live capability (direct-inject) honestly.
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
/** Bump only on a breaking capability-registry shape change; the parser refuses other versions. */
|
|
667
|
+
export const CAPABILITY_SCHEMA_VERSION = 1 as const;
|
|
668
|
+
|
|
669
|
+
/** One backend's capability — the honesty metadata that leaves the v2 record. */
|
|
670
|
+
export interface MetaCapability {
|
|
671
|
+
wakeMode: WakeMode;
|
|
672
|
+
deliveryLevel: string;
|
|
673
|
+
nativeIdLabel: string;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** The whole registry: schema version + one capability per v2 backend. */
|
|
677
|
+
export interface MetaCapabilityRegistry {
|
|
678
|
+
schemaVersion: typeof CAPABILITY_SCHEMA_VERSION;
|
|
679
|
+
backends: Record<MetaBackendV2, MetaCapability>;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const CAPABILITY_TOP_KEYS: readonly string[] = ["schemaVersion", "backends"];
|
|
683
|
+
const CAPABILITY_ENTRY_KEYS: readonly string[] = ["wakeMode", "deliveryLevel", "nativeIdLabel"];
|
|
684
|
+
|
|
685
|
+
function requireWakeMode(value: unknown, field: string): WakeMode {
|
|
686
|
+
if (value !== "self-fetch" && value !== "direct-inject") {
|
|
687
|
+
throw new MetaRecordError(`capability "${field}" must be self-fetch | direct-inject (got ${describe(value)}).`);
|
|
688
|
+
}
|
|
689
|
+
return value;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function parseCapabilityEntry(value: unknown, backend: string): MetaCapability {
|
|
693
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
694
|
+
throw new MetaRecordError(`capability for "${backend}" must be an object (got ${describe(value)}).`);
|
|
695
|
+
}
|
|
696
|
+
const obj = value as Record<string, unknown>;
|
|
697
|
+
const stray = Object.keys(obj).filter((k) => !CAPABILITY_ENTRY_KEYS.includes(k));
|
|
698
|
+
if (stray.length > 0) {
|
|
699
|
+
throw new MetaRecordError(
|
|
700
|
+
`capability for "${backend}" carries unexpected key(s) ${stray.map((k) => `"${k}"`).join(", ")} ` +
|
|
701
|
+
`(allowed: ${CAPABILITY_ENTRY_KEYS.join(", ")}).`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
wakeMode: requireWakeMode(obj.wakeMode, `${backend}.wakeMode`),
|
|
706
|
+
deliveryLevel: requireNonEmptyString(obj.deliveryLevel, `${backend}.deliveryLevel`),
|
|
707
|
+
nativeIdLabel: requireNonEmptyString(obj.nativeIdLabel, `${backend}.nativeIdLabel`),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Parse + fully validate untrusted JSON into a capability registry. Strict:
|
|
713
|
+
* schemaVersion fence, top-level + per-entry keyset, and COVERAGE — the backend
|
|
714
|
+
* keys must be exactly META_BACKENDS_V2 (no missing, no extra). A registry that
|
|
715
|
+
* forgets pi, or smuggles an unknown backend, is rejected.
|
|
716
|
+
*/
|
|
717
|
+
export function parseMetaCapabilityRegistry(json: string): MetaCapabilityRegistry {
|
|
718
|
+
let raw: unknown;
|
|
719
|
+
try {
|
|
720
|
+
raw = JSON.parse(json);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
throw new MetaRecordError(
|
|
723
|
+
`capability registry is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
727
|
+
throw new MetaRecordError(`capability registry must be a JSON object (got ${describe(raw)}).`);
|
|
728
|
+
}
|
|
729
|
+
const obj = raw as Record<string, unknown>;
|
|
730
|
+
if (obj.schemaVersion !== CAPABILITY_SCHEMA_VERSION) {
|
|
731
|
+
throw new MetaRecordError(
|
|
732
|
+
`capability registry "schemaVersion" must be ${CAPABILITY_SCHEMA_VERSION} (got ${describe(obj.schemaVersion)}).`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
const topStray = Object.keys(obj).filter((k) => !CAPABILITY_TOP_KEYS.includes(k));
|
|
736
|
+
if (topStray.length > 0) {
|
|
737
|
+
throw new MetaRecordError(
|
|
738
|
+
`capability registry carries unexpected key(s) ${topStray.map((k) => `"${k}"`).join(", ")} ` +
|
|
739
|
+
`(allowed: ${CAPABILITY_TOP_KEYS.join(", ")}).`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
const backends = obj.backends;
|
|
743
|
+
if (typeof backends !== "object" || backends === null || Array.isArray(backends)) {
|
|
744
|
+
throw new MetaRecordError(`capability registry "backends" must be an object (got ${describe(backends)}).`);
|
|
745
|
+
}
|
|
746
|
+
const present = Object.keys(backends).sort();
|
|
747
|
+
const expected = [...META_BACKENDS_V2].sort();
|
|
748
|
+
if (present.length !== expected.length || !expected.every((b, i) => b === present[i])) {
|
|
749
|
+
throw new MetaRecordError(
|
|
750
|
+
`capability registry must cover exactly ${expected.join(", ")} (got ${present.join(", ")}).`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
const entries = backends as Record<string, unknown>;
|
|
754
|
+
const out = {} as Record<MetaBackendV2, MetaCapability>;
|
|
755
|
+
for (const backend of META_BACKENDS_V2) {
|
|
756
|
+
out[backend] = parseCapabilityEntry(entries[backend], backend);
|
|
757
|
+
}
|
|
758
|
+
return { schemaVersion: CAPABILITY_SCHEMA_VERSION, backends: out };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The packaged capability registry path. Two layouts resolve:
|
|
763
|
+
* - repo / npm package: `pi-extensions/lib/` → `<root>/pi/entwurf-capabilities.json`.
|
|
764
|
+
* - bundled meta-bridge plugin: `../../pi` would ESCAPE the plugin dir (the plugin
|
|
765
|
+
* is installed under a version dir in the Claude plugin cache), so the registry
|
|
766
|
+
* travels AT the plugin root and resolves via `../` from `lib/`.
|
|
767
|
+
* meta-bridge-install.sh copies it there; doctor-meta-bridge asserts its presence.
|
|
768
|
+
* Repo path is tried first, so repo/package behaviour is unchanged; the bundle
|
|
769
|
+
* fallback only engages where the repo layout is absent.
|
|
770
|
+
*/
|
|
771
|
+
export function metaCapabilitiesFilePath(): string {
|
|
772
|
+
const repoPath = path.join(import.meta.dirname, "..", "..", "pi", "entwurf-capabilities.json");
|
|
773
|
+
if (fs.existsSync(repoPath)) return repoPath;
|
|
774
|
+
return path.join(import.meta.dirname, "..", "entwurf-capabilities.json");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
// capability live source (0.11 Stage 0 step 3D-3)
|
|
779
|
+
//
|
|
780
|
+
// 3C shipped the registry FILE + parser but left META_BACKEND_DESCRIPTORS as the
|
|
781
|
+
// authority that mint/parse read (3C header: "Cutting the live const over to this
|
|
782
|
+
// registry ... lands in step 3D"). 3D-3 is that cut-over: mint/parse now read
|
|
783
|
+
// backend honesty metadata (wakeMode/deliveryLevel) from the registry via the seam
|
|
784
|
+
// below, NOT from the const. The const survives ONLY as the drift-guard reference
|
|
785
|
+
// in check-entwurf-capabilities (registry ≡ const for the 3 existing backends), so
|
|
786
|
+
// the cut-over is behaviour-preserving. The record.delivery.wakeMode SLOT stays
|
|
787
|
+
// (its removal is 3D-4); only its SOURCE moves.
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
|
|
790
|
+
/** Memoized packaged registry; the file is immutable at runtime, so caching is honest (not stateful lying). */
|
|
791
|
+
let cachedMetaCapabilities: MetaCapabilityRegistry | null = null;
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Load + memoize the packaged capability registry — the live source of backend
|
|
795
|
+
* honesty metadata as of 3D-3. A missing/corrupt file throws (the registry is a
|
|
796
|
+
* packaged invariant; check-pack guarantees its presence).
|
|
797
|
+
*/
|
|
798
|
+
export function loadMetaCapabilityRegistry(): MetaCapabilityRegistry {
|
|
799
|
+
if (cachedMetaCapabilities === null) {
|
|
800
|
+
cachedMetaCapabilities = parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), "utf8"));
|
|
801
|
+
}
|
|
802
|
+
return cachedMetaCapabilities;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* The capability for one backend, from the registry (3D-3 live source). The
|
|
807
|
+
* optional `registry` injection lets a gate prove the value is registry-DRIVEN
|
|
808
|
+
* (feed a doctored registry → the lookup follows it), distinguishing "read from
|
|
809
|
+
* the registry" from "hardcoded off the const". Param is `MetaBackendV2` (all 4):
|
|
810
|
+
* `backends` is `Record<MetaBackendV2, …>`, so the lookup is total — mint/parse
|
|
811
|
+
* still pass the 3 `MetaBackend` values (a subset), and the v2 decider's mailbox
|
|
812
|
+
* deliverability passes the unsupported backends (which are MetaBackendV2 to the
|
|
813
|
+
* type system even though pi never reaches that call at runtime).
|
|
814
|
+
*/
|
|
815
|
+
export function metaCapabilityFor(
|
|
816
|
+
backend: MetaBackendV2,
|
|
817
|
+
registry: MetaCapabilityRegistry = loadMetaCapabilityRegistry(),
|
|
818
|
+
): MetaCapability {
|
|
819
|
+
return registry.backends[backend];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Denote-sortable on-disk filename. Body is SSOT; do NOT parse this for authority. Accepts v1 record or v2 identity. */
|
|
823
|
+
export function metaRecordFilename(record: MetaRecord | MetaIdentity): string {
|
|
824
|
+
return `${record.gardenId}.meta.json`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* THE lookup authority. Scan the record BODIES in a meta-session directory and
|
|
829
|
+
* return the one whose top-level `nativeSessionId` matches, or null. This is the
|
|
830
|
+
* `.meta.json` analog of 0.9.0 `findSessionFileById` (which header-scans pi
|
|
831
|
+
* JSONLs). NOT a filename parse, NOT an index lookup — those are at best derived
|
|
832
|
+
* caches. The directory listing + record reading is injected so this stays a
|
|
833
|
+
* pure function (the step-3 CLI supplies the real fs).
|
|
834
|
+
*
|
|
835
|
+
* The scan runs to completion (does NOT stop at the first match): the
|
|
836
|
+
* native→garden mapping MUST be unique, so two records claiming the same
|
|
837
|
+
* `nativeSessionId` is an authority ambiguity — `MetaRecordError`, fail-fast,
|
|
838
|
+
* never silently pick one (that would make `upsert` mint a second id / route a
|
|
839
|
+
* message to the wrong garden citizen).
|
|
840
|
+
*
|
|
841
|
+
* Unreadable / malformed entries are surfaced honestly via `onSkip` (a corrupt
|
|
842
|
+
* record is a real problem, not something to silently swallow); a throwing
|
|
843
|
+
* reader for one file does not abort the whole scan.
|
|
844
|
+
*/
|
|
845
|
+
export function scanByNativeId(
|
|
846
|
+
entries: readonly string[],
|
|
847
|
+
nativeSessionId: string,
|
|
848
|
+
readRecord: (filename: string) => string,
|
|
849
|
+
onSkip?: (filename: string, err: Error) => void,
|
|
850
|
+
): MetaRecord | null {
|
|
851
|
+
const target = requireNonEmptyString(nativeSessionId, "nativeSessionId");
|
|
852
|
+
const matches: { filename: string; record: MetaRecord }[] = [];
|
|
853
|
+
for (const filename of entries) {
|
|
854
|
+
if (!filename.endsWith(".meta.json")) continue;
|
|
855
|
+
let record: MetaRecord;
|
|
856
|
+
try {
|
|
857
|
+
record = parseMetaRecord(readRecord(filename));
|
|
858
|
+
} catch (err) {
|
|
859
|
+
onSkip?.(filename, err instanceof Error ? err : new Error(String(err)));
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (record.nativeSessionId === target) matches.push({ filename, record });
|
|
863
|
+
}
|
|
864
|
+
if (matches.length > 1) {
|
|
865
|
+
throw new MetaRecordError(
|
|
866
|
+
`ambiguous meta-record authority: nativeSessionId "${target}" matched ${matches.length} records ` +
|
|
867
|
+
`(${matches.map((m) => m.filename).join(", ")}). The native→garden mapping must be unique — ` +
|
|
868
|
+
`fail-fast rather than silently picking one. Remove the duplicate(s).`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
return matches.length === 1 ? (matches[0] as { record: MetaRecord }).record : null;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* The dual-read identity scan (0.11 Stage 0 step 3D-4 commit1, additive). Same
|
|
876
|
+
* lookup authority as scanByNativeId — scan the BODIES, match on top-level
|
|
877
|
+
* `nativeSessionId`, fail-fast on duplicates — but reads v1 AND v2 records (via
|
|
878
|
+
* parseMetaIdentity) and returns normalized identity. This is the scan the v2
|
|
879
|
+
* upsert uses (3D-4): once upsert writes v2, the existence check MUST recognize v2
|
|
880
|
+
* records or it would mint a duplicate id for an existing citizen (G1). scanByNativeId
|
|
881
|
+
* remains the v1-only raw scan for v1-fixture gates. Identity-only: it reads
|
|
882
|
+
* backend/nativeSessionId, never delivery.
|
|
883
|
+
*/
|
|
884
|
+
export function scanIdentityByNativeId(
|
|
885
|
+
entries: readonly string[],
|
|
886
|
+
nativeSessionId: string,
|
|
887
|
+
readRecord: (filename: string) => string,
|
|
888
|
+
onSkip?: (filename: string, err: Error) => void,
|
|
889
|
+
): MetaIdentity | null {
|
|
890
|
+
const target = requireNonEmptyString(nativeSessionId, "nativeSessionId");
|
|
891
|
+
const matches: { filename: string; identity: MetaIdentity }[] = [];
|
|
892
|
+
for (const filename of entries) {
|
|
893
|
+
if (!filename.endsWith(".meta.json")) continue;
|
|
894
|
+
let identity: MetaIdentity;
|
|
895
|
+
try {
|
|
896
|
+
identity = parseMetaIdentity(readRecord(filename));
|
|
897
|
+
} catch (err) {
|
|
898
|
+
onSkip?.(filename, err instanceof Error ? err : new Error(String(err)));
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (identity.nativeSessionId === target) matches.push({ filename, identity });
|
|
902
|
+
}
|
|
903
|
+
if (matches.length > 1) {
|
|
904
|
+
throw new MetaRecordError(
|
|
905
|
+
`ambiguous meta-record authority: nativeSessionId "${target}" matched ${matches.length} records ` +
|
|
906
|
+
`(${matches.map((m) => m.filename).join(", ")}). The native→garden mapping must be unique — ` +
|
|
907
|
+
`fail-fast rather than silently picking one. Remove the duplicate(s).`,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
return matches.length === 1 ? (matches[0] as { identity: MetaIdentity }).identity : null;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** One unreadable meta-record, surfaced as an explicit fact — file + message
|
|
914
|
+
* ONLY, never a half-parsed identity field. A salvaged gid-looking string
|
|
915
|
+
* presented as a fact is a synthetic backdoor; verbatim-or-nothing. */
|
|
916
|
+
export interface MetaRecordReadError {
|
|
917
|
+
filename: string;
|
|
918
|
+
message: string;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
export interface ListIdentitiesResult {
|
|
922
|
+
identities: MetaIdentity[];
|
|
923
|
+
errors: MetaRecordReadError[];
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Scan every meta-record in a store into identities + explicit read errors.
|
|
928
|
+
* Pure over injected (entries, readRecord) so gates drive it without IO; the
|
|
929
|
+
* fact-provider (slice 4b) supplies the real readdir/readFile.
|
|
930
|
+
*
|
|
931
|
+
* A record that fails to parse — or whose body gardenId drifts from its filename
|
|
932
|
+
* (the same authority check as `readMetaIdentityByGardenId`) — is NEITHER
|
|
933
|
+
* silently skipped (that hides a broken citizen = lie by omission) NOR allowed to
|
|
934
|
+
* throw the whole listing (one corrupt file must not blind `entwurf_peers` — the
|
|
935
|
+
* 0.10 "corrupt blocks registration forever" lesson). It becomes an explicit
|
|
936
|
+
* error entry carrying ONLY filename + message. Duplicate gardenId across files
|
|
937
|
+
* is impossible: the filename IS `<gardenId>.meta.json`, so the filesystem
|
|
938
|
+
* already enforces uniqueness — only body/filename drift can split authority.
|
|
939
|
+
*
|
|
940
|
+
* mode "collect" (default) returns partial results; "strict" throws if ANY
|
|
941
|
+
* record was unreadable (doctor / gate callers wanting all-or-nothing).
|
|
942
|
+
*/
|
|
943
|
+
export function listAllMetaIdentities(
|
|
944
|
+
entries: readonly string[],
|
|
945
|
+
readRecord: (filename: string) => string,
|
|
946
|
+
opts: { mode?: "collect" | "strict" } = {},
|
|
947
|
+
): ListIdentitiesResult {
|
|
948
|
+
const identities: MetaIdentity[] = [];
|
|
949
|
+
const errors: MetaRecordReadError[] = [];
|
|
950
|
+
for (const filename of entries) {
|
|
951
|
+
if (!filename.endsWith(".meta.json")) continue;
|
|
952
|
+
let identity: MetaIdentity;
|
|
953
|
+
try {
|
|
954
|
+
identity = parseMetaIdentity(readRecord(filename));
|
|
955
|
+
} catch (err) {
|
|
956
|
+
errors.push({ filename, message: err instanceof Error ? err.message : String(err) });
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
const expected = filename.slice(0, -".meta.json".length);
|
|
960
|
+
if (identity.gardenId !== expected) {
|
|
961
|
+
errors.push({
|
|
962
|
+
filename,
|
|
963
|
+
message: `body/filename drift: body gardenId "${identity.gardenId}" ≠ filename. The body is the authority; this file is corrupt.`,
|
|
964
|
+
});
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
identities.push(identity);
|
|
968
|
+
}
|
|
969
|
+
if (opts.mode === "strict" && errors.length > 0) {
|
|
970
|
+
throw new MetaRecordError(
|
|
971
|
+
`listAllMetaIdentities(strict): ${errors.length} unreadable meta-record(s): ${errors
|
|
972
|
+
.map((e) => `${e.filename} (${e.message})`)
|
|
973
|
+
.join("; ")}`,
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
identities.sort((a, b) => (a.gardenId < b.gardenId ? -1 : a.gardenId > b.gardenId ? 1 : 0));
|
|
977
|
+
errors.sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0));
|
|
978
|
+
return { identities, errors };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export type UpsertAction = "create" | "attach";
|
|
982
|
+
|
|
983
|
+
export interface UpsertDecision {
|
|
984
|
+
action: UpsertAction;
|
|
985
|
+
record: MetaIdentity;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* The pure core of the `upsert` CLI (3D-4: v2 identity). Keyed on RECORD
|
|
990
|
+
* EXISTENCE, never on a backend `source` field:
|
|
991
|
+
* - existing present → ATTACH: keep identity (gardenId, createdAt,
|
|
992
|
+
* nativeSessionId), bump recordUpdatedAt, and apply the 3-value merge to the
|
|
993
|
+
* nullable axes + always-refresh cwd. Identity drift (a different backend for
|
|
994
|
+
* the same nativeSessionId) is corruption → throw.
|
|
995
|
+
* - absent → CREATE: mint a fresh v2 identity.
|
|
996
|
+
*
|
|
997
|
+
* 3-value attach merge (G5): for model/transcriptPath/parentGardenId an input of
|
|
998
|
+
* `undefined` KEEPS the existing value (a pi-birth caller that does not know the
|
|
999
|
+
* transcript must not wipe a previously-recorded one), `null` explicitly clears
|
|
1000
|
+
* it, a string sets it. cwd is required and always refreshed.
|
|
1001
|
+
*
|
|
1002
|
+
* Idempotent by construction: calling it twice with the same input yields one
|
|
1003
|
+
* attach after the first create, never a second id. `existing` is the normalized
|
|
1004
|
+
* identity from scanIdentityByNativeId (dual-read v1+v2).
|
|
1005
|
+
*/
|
|
1006
|
+
export function decideUpsert(
|
|
1007
|
+
existing: MetaIdentity | null,
|
|
1008
|
+
input: MetaIdentityMintInput,
|
|
1009
|
+
now: Date = new Date(),
|
|
1010
|
+
): UpsertDecision {
|
|
1011
|
+
const backend = requireBackendV2(input.backend);
|
|
1012
|
+
const nativeSessionId = requireNonEmptyString(input.nativeSessionId, "nativeSessionId");
|
|
1013
|
+
const cwd = requireNonEmptyString(input.cwd, "cwd");
|
|
1014
|
+
|
|
1015
|
+
if (existing === null) {
|
|
1016
|
+
return { action: "create", record: mintMetaIdentity(input, now) };
|
|
1017
|
+
}
|
|
1018
|
+
if (existing.nativeSessionId !== nativeSessionId) {
|
|
1019
|
+
throw new MetaRecordError(
|
|
1020
|
+
`decideUpsert called with existing record for a different nativeSessionId ` +
|
|
1021
|
+
`(existing="${existing.nativeSessionId}", input="${nativeSessionId}"). ` +
|
|
1022
|
+
`The caller must pass the record found by scanIdentityByNativeId(input.nativeSessionId).`,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
if (existing.backend !== backend) {
|
|
1026
|
+
throw new MetaRecordError(
|
|
1027
|
+
`meta-record identity drift: nativeSessionId "${nativeSessionId}" is bound to backend ` +
|
|
1028
|
+
`"${existing.backend}" but upsert input says "${backend}". A native session cannot change backend.`,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
// 3-value merge (G5): undefined keeps existing, null clears, string sets. The
|
|
1032
|
+
// nullable axes are validated the same way mint validates them.
|
|
1033
|
+
const model = input.model === undefined ? existing.model : requireNullableString(input.model, "model");
|
|
1034
|
+
const transcriptPath =
|
|
1035
|
+
input.transcriptPath === undefined
|
|
1036
|
+
? existing.transcriptPath
|
|
1037
|
+
: requireNullableString(input.transcriptPath, "transcriptPath");
|
|
1038
|
+
const parentGardenId =
|
|
1039
|
+
input.parentGardenId === undefined
|
|
1040
|
+
? existing.parentGardenId
|
|
1041
|
+
: requireNullableGardenId(input.parentGardenId, "parentGardenId");
|
|
1042
|
+
const isEntwurf = input.isEntwurf === undefined ? existing.isEntwurf : requireBoolean(input.isEntwurf, "isEntwurf");
|
|
1043
|
+
return {
|
|
1044
|
+
action: "attach",
|
|
1045
|
+
record: { ...existing, cwd, model, transcriptPath, parentGardenId, isEntwurf, recordUpdatedAt: isoNow(now) },
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ---------------------------------------------------------------------------
|
|
1050
|
+
// read-receipt mutators — V1-RECORD ONLY (3D-4 H3). These mutate record.delivery,
|
|
1051
|
+
// which exists only on the v1 schema. The LIVE enqueue/read path no longer calls
|
|
1052
|
+
// them (3D-4 the cut: the receipt lives in the mailbox state store, stamped by
|
|
1053
|
+
// stampMailboxReceipt). They are retained for the v1-fixture / dual-read gates that
|
|
1054
|
+
// still exercise a raw v1 record; do NOT re-wire them into the live path.
|
|
1055
|
+
// ---------------------------------------------------------------------------
|
|
1056
|
+
|
|
1057
|
+
/** A sender enqueued a body to this peer's mailbox. (v1-record only — see section note.) */
|
|
1058
|
+
export function markEnqueued(record: MetaRecord, now: Date = new Date()): MetaRecord {
|
|
1059
|
+
return { ...record, delivery: { ...record.delivery, lastEnqueuedAt: isoNow(now) } };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** The doorbell rang / body injected ("`.delivered`"). For self-fetch ≠ read. */
|
|
1063
|
+
export function markDelivered(record: MetaRecord, now: Date = new Date()): MetaRecord {
|
|
1064
|
+
return { ...record, delivery: { ...record.delivery, lastDeliveredAt: isoNow(now) } };
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** The inbox-read MCP call — the real read-receipt (makes Claude D7 observable). */
|
|
1068
|
+
export function markRead(record: MetaRecord, now: Date = new Date()): MetaRecord {
|
|
1069
|
+
return { ...record, delivery: { ...record.delivery, lastReadAt: isoNow(now) } };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// ---------------------------------------------------------------------------
|
|
1073
|
+
// FS-bound store (step 3) — the thin real-filesystem wrapper around the pure
|
|
1074
|
+
// core. Only node builtins beyond the pure layer, so the deterministic gate
|
|
1075
|
+
// stays strip-types clean (see module header for why this is not a sibling file).
|
|
1076
|
+
// ---------------------------------------------------------------------------
|
|
1077
|
+
|
|
1078
|
+
function expandTilde(p: string): string {
|
|
1079
|
+
if (p === "~") return os.homedir();
|
|
1080
|
+
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
1081
|
+
return p;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* The pi agent dir — the persistence root pi owns. `PI_CODING_AGENT_DIR` lets an
|
|
1086
|
+
* isolated install / test relocate it (symmetric with how pi's own sessions
|
|
1087
|
+
* isolate); otherwise it is the fixed `~/.pi/agent`. A stable `~/` path, so the
|
|
1088
|
+
* meta-bridge hook never needs this baked into config — it resolves at runtime.
|
|
1089
|
+
*/
|
|
1090
|
+
function piAgentDir(): string {
|
|
1091
|
+
return process.env.PI_CODING_AGENT_DIR
|
|
1092
|
+
? path.resolve(expandTilde(process.env.PI_CODING_AGENT_DIR))
|
|
1093
|
+
: path.join(os.homedir(), ".pi", "agent");
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Where meta-records live. Under the pi agent dir (pi owns persistence), so an
|
|
1098
|
+
* isolated install / test that sets `PI_CODING_AGENT_DIR` gets isolated
|
|
1099
|
+
* meta-sessions too — symmetric with how pi's own sessions isolate. A direct
|
|
1100
|
+
* `ENTWURF_META_SESSIONS_DIR` override wins (used by tests / unusual deployments).
|
|
1101
|
+
*/
|
|
1102
|
+
export function defaultMetaSessionsDir(): string {
|
|
1103
|
+
if (process.env.ENTWURF_META_SESSIONS_DIR) return path.resolve(expandTilde(process.env.ENTWURF_META_SESSIONS_DIR));
|
|
1104
|
+
return path.join(piAgentDir(), "meta-sessions");
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Where per-garden-id idle-wake mailboxes live: `<pi-agent-dir>/meta-mailbox`.
|
|
1109
|
+
* Deliberately a SIBLING of meta-sessions, not nested inside it — the record
|
|
1110
|
+
* store is the authority (scanned for identity) while the mailbox is volatile
|
|
1111
|
+
* signal/body traffic; keeping them apart means a mailbox poke never risks a
|
|
1112
|
+
* record-dir readdir picking up a non-record file. The watched signal for a
|
|
1113
|
+
* session is `<this>/<gardenId>/inbox.signal`. Same runtime resolution as
|
|
1114
|
+
* meta-sessions (no config baking); `ENTWURF_META_MAILBOX_DIR` overrides for tests.
|
|
1115
|
+
*/
|
|
1116
|
+
export function defaultMetaMailboxDir(): string {
|
|
1117
|
+
if (process.env.ENTWURF_META_MAILBOX_DIR) return path.resolve(expandTilde(process.env.ENTWURF_META_MAILBOX_DIR));
|
|
1118
|
+
return path.join(piAgentDir(), "meta-mailbox");
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Where native-backend SENDER markers live: `<pi-agent-dir>/meta-senders`.
|
|
1123
|
+
*
|
|
1124
|
+
* The problem this closes: a native Claude Code session that SENDS via the
|
|
1125
|
+
* user-scope entwurf-bridge MCP has no `PI_SESSION_ID` — at tool-call time the
|
|
1126
|
+
* MCP process does not know which garden-id session it belongs to, so the sender
|
|
1127
|
+
* envelope degrades to anonymous `external-mcp` and the receiver has no reply
|
|
1128
|
+
* address. The hook DOES know the garden-id (it just minted the record), and the
|
|
1129
|
+
* hook + the MCP child run under the SAME Claude Code parent process. So the hook
|
|
1130
|
+
* writes a marker keyed by that parent pid; the MCP reads the marker for its OWN
|
|
1131
|
+
* `process.ppid` and promotes itself to a replyable meta-session sender. This
|
|
1132
|
+
* uses process ancestry, NOT cwd inference (same repo / multiple sessions would
|
|
1133
|
+
* make cwd ambiguous). `ENTWURF_META_SENDERS_DIR` overrides for tests.
|
|
1134
|
+
*/
|
|
1135
|
+
export function defaultMetaSendersDir(): string {
|
|
1136
|
+
if (process.env.ENTWURF_META_SENDERS_DIR) return path.resolve(expandTilde(process.env.ENTWURF_META_SENDERS_DIR));
|
|
1137
|
+
return path.join(piAgentDir(), "meta-senders");
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Where native-backend RECEIVER presence markers live: `<pi-agent-dir>/meta-receivers`.
|
|
1142
|
+
*
|
|
1143
|
+
* The problem this closes (SE-2): a meta-record proves a session once EXISTED, not
|
|
1144
|
+
* that it is still a live receiver that a reply could reach. A self-fetch backend
|
|
1145
|
+
* (Claude Code) has no control socket to probe, so "is this receiver active right
|
|
1146
|
+
* now?" needs its own signal. The SessionStart/CwdChanged/FileChanged hook — the
|
|
1147
|
+
* event that actually arms the watchPaths idle-wake — writes a presence marker keyed
|
|
1148
|
+
* by GARDEN id (the universal address a sender targets), carrying the watch owner pid
|
|
1149
|
+
* + its start-key. A reader trusts it only while that pid is still the same live
|
|
1150
|
+
* process (start-key match); a terminated session leaves a marker whose owner is gone,
|
|
1151
|
+
* so it reads as inactive instead of a ghost active-receiver. UNLIKE the sender marker
|
|
1152
|
+
* (keyed by owner pid, a pid→garden hint), this is keyed by garden id because the
|
|
1153
|
+
* deliverability question starts from a target garden id. `ENTWURF_META_RECEIVERS_DIR`
|
|
1154
|
+
* overrides for tests.
|
|
1155
|
+
*/
|
|
1156
|
+
export function defaultMetaReceiversDir(): string {
|
|
1157
|
+
if (process.env.ENTWURF_META_RECEIVERS_DIR) return path.resolve(expandTilde(process.env.ENTWURF_META_RECEIVERS_DIR));
|
|
1158
|
+
return path.join(piAgentDir(), "meta-receivers");
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* A boot-unique identity for a live process: pid is reused, but pid + start-time
|
|
1163
|
+
* is unique within a boot. Linux reads `/proc/<pid>/stat` field 22 (starttime in
|
|
1164
|
+
* clock ticks); macOS/BSD falls back to `ps -o lstart=`. Returns "" when the pid
|
|
1165
|
+
* is gone or unreadable — a "" key never matches, so a dead/reused owner fails
|
|
1166
|
+
* the marker check. This is what stops a stale marker (process exited, pid reused
|
|
1167
|
+
* by a new Claude session) from granting the wrong garden-id sender identity.
|
|
1168
|
+
*/
|
|
1169
|
+
export function processStartKey(pid: number): string {
|
|
1170
|
+
if (!Number.isInteger(pid) || pid <= 0) return "";
|
|
1171
|
+
try {
|
|
1172
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
1173
|
+
// comm (field 2) is parenthesized and may contain spaces/parens — split AFTER the last ')'.
|
|
1174
|
+
const fields = stat
|
|
1175
|
+
.slice(stat.lastIndexOf(")") + 1)
|
|
1176
|
+
.trim()
|
|
1177
|
+
.split(/\s+/);
|
|
1178
|
+
// after comm: index 0 = state(f3), 1 = ppid(f4), … 19 = starttime(f22).
|
|
1179
|
+
const starttime = fields[19];
|
|
1180
|
+
if (starttime && /^\d+$/.test(starttime)) return `linux:${starttime}`;
|
|
1181
|
+
} catch {
|
|
1182
|
+
// not Linux / no procfs
|
|
1183
|
+
}
|
|
1184
|
+
try {
|
|
1185
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
|
|
1186
|
+
if (out) return `ps:${out}`;
|
|
1187
|
+
} catch {
|
|
1188
|
+
// pid gone or ps unavailable
|
|
1189
|
+
}
|
|
1190
|
+
return "";
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** The parent pid of a pid (one ancestry step), or null when unknown. */
|
|
1194
|
+
export function parentPid(pid: number): number | null {
|
|
1195
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
1196
|
+
try {
|
|
1197
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
1198
|
+
const fields = stat
|
|
1199
|
+
.slice(stat.lastIndexOf(")") + 1)
|
|
1200
|
+
.trim()
|
|
1201
|
+
.split(/\s+/);
|
|
1202
|
+
const ppid = Number(fields[1]); // f4
|
|
1203
|
+
if (Number.isInteger(ppid) && ppid > 0) return ppid;
|
|
1204
|
+
} catch {
|
|
1205
|
+
// not Linux
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
const out = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" }).trim();
|
|
1209
|
+
const ppid = Number(out);
|
|
1210
|
+
if (Number.isInteger(ppid) && ppid > 0) return ppid;
|
|
1211
|
+
} catch {
|
|
1212
|
+
// pid gone
|
|
1213
|
+
}
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
export interface MetaSenderMarker {
|
|
1218
|
+
backend: MetaBackend;
|
|
1219
|
+
gardenId: string;
|
|
1220
|
+
nativeSessionId: string;
|
|
1221
|
+
cwd: string;
|
|
1222
|
+
/** The pid this marker is keyed to (the shared native runner / Claude parent). */
|
|
1223
|
+
ownerPid: number;
|
|
1224
|
+
/** processStartKey(ownerPid) at write time — the pid-reuse guard. */
|
|
1225
|
+
ownerStartKey: string;
|
|
1226
|
+
updatedAt: string;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** `<sendersDir>/<backend>/<ownerPid>.json` — keyed by the shared parent pid. */
|
|
1230
|
+
export function metaSenderMarkerPath(
|
|
1231
|
+
backend: MetaBackend,
|
|
1232
|
+
ownerPid: number,
|
|
1233
|
+
sendersDir: string = defaultMetaSendersDir(),
|
|
1234
|
+
): string {
|
|
1235
|
+
return path.join(sendersDir, backend, `${ownerPid}.json`);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
export interface WriteMetaSenderMarkerOptions {
|
|
1239
|
+
backend: MetaBackend;
|
|
1240
|
+
gardenId: string;
|
|
1241
|
+
nativeSessionId: string;
|
|
1242
|
+
cwd: string;
|
|
1243
|
+
ownerPid: number;
|
|
1244
|
+
sendersDir?: string;
|
|
1245
|
+
now?: Date;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** Write (atomically) the sender marker for a native session's parent pid. */
|
|
1249
|
+
export function writeMetaSenderMarker(opts: WriteMetaSenderMarkerOptions): string {
|
|
1250
|
+
const backend = requireBackend(opts.backend);
|
|
1251
|
+
const gardenId = requireGardenId(opts.gardenId);
|
|
1252
|
+
const file = metaSenderMarkerPath(backend, opts.ownerPid, opts.sendersDir ?? defaultMetaSendersDir());
|
|
1253
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1254
|
+
const marker: MetaSenderMarker = {
|
|
1255
|
+
backend,
|
|
1256
|
+
gardenId,
|
|
1257
|
+
nativeSessionId: requireNonEmptyString(opts.nativeSessionId, "nativeSessionId"),
|
|
1258
|
+
cwd: requireNonEmptyString(opts.cwd, "cwd"),
|
|
1259
|
+
ownerPid: opts.ownerPid,
|
|
1260
|
+
ownerStartKey: processStartKey(opts.ownerPid),
|
|
1261
|
+
updatedAt: isoNow(opts.now ?? new Date()),
|
|
1262
|
+
};
|
|
1263
|
+
const tmp = `${file}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
1264
|
+
fs.writeFileSync(tmp, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
|
1265
|
+
fs.renameSync(tmp, file);
|
|
1266
|
+
return file;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
export interface ReadMetaSenderMarkerOptions {
|
|
1270
|
+
/** Explicit marker file (test / explicit wiring). Wins over backend+ownerPid. */
|
|
1271
|
+
markerPath?: string;
|
|
1272
|
+
backend?: MetaBackend;
|
|
1273
|
+
ownerPid?: number;
|
|
1274
|
+
sendersDir?: string;
|
|
1275
|
+
/** Run the pid-reuse guard (verify the owner pid is still live). Default true —
|
|
1276
|
+
* set false only for unit assertions that exercise the marker without a live owner. */
|
|
1277
|
+
verifyOwner?: boolean;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Read the sender marker for this MCP process's owner. Returns null when absent
|
|
1282
|
+
* or corrupt — a marker we cannot trust means "no authoritative sender", which
|
|
1283
|
+
* the caller turns into external-non-replyable (or a hard reject under
|
|
1284
|
+
* REQUIRE_META_SENDER). Never throws: an unreadable marker must not break a send.
|
|
1285
|
+
*/
|
|
1286
|
+
export function readMetaSenderMarker(opts: ReadMetaSenderMarkerOptions): MetaSenderMarker | null {
|
|
1287
|
+
let file = opts.markerPath;
|
|
1288
|
+
if (!file && opts.backend && typeof opts.ownerPid === "number") {
|
|
1289
|
+
file = metaSenderMarkerPath(opts.backend, opts.ownerPid, opts.sendersDir ?? defaultMetaSendersDir());
|
|
1290
|
+
}
|
|
1291
|
+
if (!file || !fs.existsSync(file)) return null;
|
|
1292
|
+
try {
|
|
1293
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1294
|
+
const marker: MetaSenderMarker = {
|
|
1295
|
+
backend: requireBackend(raw.backend),
|
|
1296
|
+
gardenId: requireGardenId(raw.gardenId),
|
|
1297
|
+
nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
|
|
1298
|
+
cwd: requireNonEmptyString(raw.cwd, "cwd"),
|
|
1299
|
+
ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
|
|
1300
|
+
ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
|
|
1301
|
+
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
1302
|
+
};
|
|
1303
|
+
// pid-reuse guard (unless explicitly disabled): the owner pid must STILL be
|
|
1304
|
+
// the same process that wrote the marker. A bare pid is reused; pid+startKey
|
|
1305
|
+
// is boot-unique, so a stale marker from a dead session fails here instead of
|
|
1306
|
+
// granting a wrong-identity send.
|
|
1307
|
+
if (opts.verifyOwner !== false) {
|
|
1308
|
+
if (!Number.isInteger(marker.ownerPid)) return null;
|
|
1309
|
+
const liveKey = processStartKey(marker.ownerPid);
|
|
1310
|
+
if (!liveKey || liveKey !== marker.ownerStartKey) return null;
|
|
1311
|
+
}
|
|
1312
|
+
return marker;
|
|
1313
|
+
} catch {
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// ── meta-receiver presence marker (SE-2 active-receiver signal) ──────────────
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* The arm-capable hook events. Only these can emit watchPaths (and therefore arm
|
|
1322
|
+
* the idle-wake), so only these write a receiver presence marker. UserPromptSubmit
|
|
1323
|
+
* is deliberately absent: it can backfill the record but cannot re-arm the watch, so
|
|
1324
|
+
* it must NOT mint or refresh an "active receiver" claim it cannot back.
|
|
1325
|
+
*/
|
|
1326
|
+
export const META_RECEIVER_ARM_PROVENANCES = ["session-start", "cwd-changed", "file-changed"] as const;
|
|
1327
|
+
export type MetaReceiverArmProvenance = (typeof META_RECEIVER_ARM_PROVENANCES)[number];
|
|
1328
|
+
|
|
1329
|
+
function requireArmProvenance(value: unknown): MetaReceiverArmProvenance {
|
|
1330
|
+
if (typeof value === "string" && (META_RECEIVER_ARM_PROVENANCES as readonly string[]).includes(value)) {
|
|
1331
|
+
return value as MetaReceiverArmProvenance;
|
|
1332
|
+
}
|
|
1333
|
+
throw new Error(
|
|
1334
|
+
`invalid armProvenance: ${JSON.stringify(value)} (expected one of ${META_RECEIVER_ARM_PROVENANCES.join(", ")})`,
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
export interface MetaReceiverMarker {
|
|
1339
|
+
gardenId: string;
|
|
1340
|
+
backend: MetaBackend;
|
|
1341
|
+
nativeSessionId: string;
|
|
1342
|
+
/** The pid holding the watchPaths idle-wake subscription (the native CLI = hook's process.ppid). */
|
|
1343
|
+
ownerPid: number;
|
|
1344
|
+
/** processStartKey(ownerPid) at write time — the dead-owner / pid-reuse guard. */
|
|
1345
|
+
ownerStartKey: string;
|
|
1346
|
+
/** The kind of process that owns the watch. Currently always the native CLI, not the plugin host. */
|
|
1347
|
+
ownerKind: string;
|
|
1348
|
+
/** Which arm-capable event wrote this presence (never user-prompt-submit). */
|
|
1349
|
+
armProvenance: MetaReceiverArmProvenance;
|
|
1350
|
+
updatedAt: string;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** `<receiversDir>/<gardenId>.json` — keyed by garden id (the universal address). */
|
|
1354
|
+
export function metaReceiverMarkerPath(gardenId: string, receiversDir: string = defaultMetaReceiversDir()): string {
|
|
1355
|
+
return path.join(receiversDir, `${requireGardenId(gardenId)}.json`);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
export interface WriteMetaReceiverMarkerOptions {
|
|
1359
|
+
gardenId: string;
|
|
1360
|
+
backend: MetaBackend;
|
|
1361
|
+
nativeSessionId: string;
|
|
1362
|
+
ownerPid: number;
|
|
1363
|
+
armProvenance: MetaReceiverArmProvenance;
|
|
1364
|
+
/** Defaults to "claude-code-cli" — the watchPaths subscriber. */
|
|
1365
|
+
ownerKind?: string;
|
|
1366
|
+
receiversDir?: string;
|
|
1367
|
+
now?: Date;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** Write (atomically) the receiver presence marker for a garden id. */
|
|
1371
|
+
export function writeMetaReceiverMarker(opts: WriteMetaReceiverMarkerOptions): string {
|
|
1372
|
+
const gardenId = requireGardenId(opts.gardenId);
|
|
1373
|
+
const backend = requireBackend(opts.backend);
|
|
1374
|
+
const file = metaReceiverMarkerPath(gardenId, opts.receiversDir ?? defaultMetaReceiversDir());
|
|
1375
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1376
|
+
const marker: MetaReceiverMarker = {
|
|
1377
|
+
gardenId,
|
|
1378
|
+
backend,
|
|
1379
|
+
nativeSessionId: requireNonEmptyString(opts.nativeSessionId, "nativeSessionId"),
|
|
1380
|
+
ownerPid: opts.ownerPid,
|
|
1381
|
+
ownerStartKey: processStartKey(opts.ownerPid),
|
|
1382
|
+
ownerKind: requireNonEmptyString(opts.ownerKind ?? "claude-code-cli", "ownerKind"),
|
|
1383
|
+
armProvenance: requireArmProvenance(opts.armProvenance),
|
|
1384
|
+
updatedAt: isoNow(opts.now ?? new Date()),
|
|
1385
|
+
};
|
|
1386
|
+
const tmp = `${file}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
1387
|
+
fs.writeFileSync(tmp, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
|
1388
|
+
fs.renameSync(tmp, file);
|
|
1389
|
+
return file;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
export interface ReadMetaReceiverMarkerOptions {
|
|
1393
|
+
/** Explicit marker file (test / explicit wiring). Wins over gardenId. */
|
|
1394
|
+
markerPath?: string;
|
|
1395
|
+
gardenId?: string;
|
|
1396
|
+
receiversDir?: string;
|
|
1397
|
+
/** Run the dead-owner / pid-reuse guard (verify the owner pid is still the same live process).
|
|
1398
|
+
* Default true — set false only for unit assertions that inspect a marker without a live owner. */
|
|
1399
|
+
verifyOwner?: boolean;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Read the receiver presence marker for a garden id. Returns null when absent,
|
|
1404
|
+
* corrupt, or (under verifyOwner) the owner pid is no longer the same live process —
|
|
1405
|
+
* each means "no active receiver", which the deliverability predicate turns into
|
|
1406
|
+
* not-deliverable. Never throws: an unreadable marker must not break a send path.
|
|
1407
|
+
* Record-backing is NOT checked here (the caller / predicate supplies recordBacked
|
|
1408
|
+
* as an explicit fact, so an absent record and a dead owner stay distinguishable).
|
|
1409
|
+
*/
|
|
1410
|
+
export function readMetaReceiverMarker(opts: ReadMetaReceiverMarkerOptions): MetaReceiverMarker | null {
|
|
1411
|
+
let file = opts.markerPath;
|
|
1412
|
+
if (!file && opts.gardenId) {
|
|
1413
|
+
file = metaReceiverMarkerPath(opts.gardenId, opts.receiversDir ?? defaultMetaReceiversDir());
|
|
1414
|
+
}
|
|
1415
|
+
if (!file || !fs.existsSync(file)) return null;
|
|
1416
|
+
try {
|
|
1417
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1418
|
+
const marker: MetaReceiverMarker = {
|
|
1419
|
+
gardenId: requireGardenId(raw.gardenId),
|
|
1420
|
+
backend: requireBackend(raw.backend),
|
|
1421
|
+
nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
|
|
1422
|
+
ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
|
|
1423
|
+
ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
|
|
1424
|
+
ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
|
|
1425
|
+
armProvenance: requireArmProvenance(raw.armProvenance),
|
|
1426
|
+
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
1427
|
+
};
|
|
1428
|
+
if (opts.verifyOwner !== false) {
|
|
1429
|
+
if (!Number.isInteger(marker.ownerPid)) return null;
|
|
1430
|
+
const liveKey = processStartKey(marker.ownerPid);
|
|
1431
|
+
if (!liveKey || liveKey !== marker.ownerStartKey) return null;
|
|
1432
|
+
}
|
|
1433
|
+
return marker;
|
|
1434
|
+
} catch {
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
export interface UpsertMetaSessionOptions {
|
|
1440
|
+
input: MetaIdentityMintInput;
|
|
1441
|
+
/** Override the store directory (defaults to {@link defaultMetaSessionsDir}). */
|
|
1442
|
+
dir?: string;
|
|
1443
|
+
/** Override the mailbox dir (defaults to {@link defaultMetaMailboxDir}) — only the v1→v2 receipt migration touches it. */
|
|
1444
|
+
mailboxDir?: string;
|
|
1445
|
+
now?: Date;
|
|
1446
|
+
onSkip?: (filename: string, err: Error) => void;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
export interface UpsertMetaSessionResult {
|
|
1450
|
+
action: UpsertAction;
|
|
1451
|
+
record: MetaIdentity;
|
|
1452
|
+
dir: string;
|
|
1453
|
+
/** Absolute path of the written record. */
|
|
1454
|
+
path: string;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Idempotent fs upsert (3D-4: writes v2 identity). Scan the store by
|
|
1459
|
+
* `nativeSessionId` with the dual-read identity scan (sees v1 AND v2, so an
|
|
1460
|
+
* existing citizen is found regardless of schema — never duplicate-mint, G1),
|
|
1461
|
+
* decide create vs attach on EXISTENCE, and write atomically as v2. On attach the
|
|
1462
|
+
* file is the existing garden id's record (same path, rewritten in place, v1→v2);
|
|
1463
|
+
* on create it is a fresh `<gardenId>.meta.json`. A duplicate `nativeSessionId`
|
|
1464
|
+
* throws (via the scan) rather than silently picking one.
|
|
1465
|
+
*
|
|
1466
|
+
* Crash-order (3D-4): when the matched file is still v1, its delivery receipts are
|
|
1467
|
+
* migrated to the mailbox state store BEFORE the v2 rewrite. If the process dies
|
|
1468
|
+
* between the two, the record is still v1 → the next attach re-migrates (state-wins
|
|
1469
|
+
* merge is idempotent), so no receipt is lost. The reverse order would lose the
|
|
1470
|
+
* receipt permanently. The write is tmp-file + rename so a crash never leaves a
|
|
1471
|
+
* half-written record (#30 crash-safety).
|
|
1472
|
+
*/
|
|
1473
|
+
export function upsertMetaSession(opts: UpsertMetaSessionOptions): UpsertMetaSessionResult {
|
|
1474
|
+
const dir = path.resolve(expandTilde(opts.dir ?? defaultMetaSessionsDir()));
|
|
1475
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1476
|
+
const entries = fs.readdirSync(dir);
|
|
1477
|
+
const readRaw = (filename: string) => fs.readFileSync(path.join(dir, filename), "utf8");
|
|
1478
|
+
const existing = scanIdentityByNativeId(entries, opts.input.nativeSessionId, readRaw, opts.onSkip);
|
|
1479
|
+
|
|
1480
|
+
// Crash-order: migrate a v1 file's receipts to mailbox state BEFORE rewriting it
|
|
1481
|
+
// as v2. Re-read the matched file raw to see if it is still v1 (carries delivery).
|
|
1482
|
+
if (existing !== null) {
|
|
1483
|
+
const raw = parseMetaRecordAny(readRaw(`${existing.gardenId}.meta.json`));
|
|
1484
|
+
if (raw.schemaVersion === META_SCHEMA_VERSION) {
|
|
1485
|
+
migrateV1DeliveryReceipts({ gardenId: existing.gardenId, delivery: raw.delivery, mailboxDir: opts.mailboxDir });
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
const decision = decideUpsert(existing, opts.input, opts.now);
|
|
1490
|
+
const file = path.join(dir, metaRecordFilename(decision.record));
|
|
1491
|
+
atomicWriteIdentity(file, decision.record);
|
|
1492
|
+
return { action: decision.action, record: decision.record, dir, path: file };
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/** tmp-file + rename so a crash never leaves a half-written record (v2 identity write). */
|
|
1496
|
+
function atomicWriteIdentity(file: string, identity: MetaIdentity): void {
|
|
1497
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1498
|
+
fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
|
|
1499
|
+
fs.renameSync(tmp, file);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// ---------------------------------------------------------------------------
|
|
1503
|
+
// Mailbox delivery (step 6) — addressed by GARDEN ID. The record store is the
|
|
1504
|
+
// authority (a sender may only deliver to a known garden citizen); the mailbox
|
|
1505
|
+
// under defaultMetaMailboxDir() carries the volatile signal + message bodies.
|
|
1506
|
+
//
|
|
1507
|
+
// The honest delivery contract (do not blur these):
|
|
1508
|
+
// - enqueue : a sender wrote a `.msg` body + poked `inbox.signal`
|
|
1509
|
+
// (markEnqueued). The poke is what the plugin's FileChanged
|
|
1510
|
+
// doorbell watches — it wakes an idle session.
|
|
1511
|
+
// - `.msg.delivered`: the doorbell rang (FileChanged moved `.msg` ->
|
|
1512
|
+
// `.msg.delivered` and announced it). A FILESYSTEM marker =
|
|
1513
|
+
// WAKE ATTEMPT, NOT a read.
|
|
1514
|
+
// - lastReadAt : the model called readMetaInbox (the inbox-read tool) and
|
|
1515
|
+
// the body actually reached model-visible context. THIS is
|
|
1516
|
+
// the real D7 read-receipt. For Claude self-fetch, delivered
|
|
1517
|
+
// != read; readMetaInbox is the only thing that sets it.
|
|
1518
|
+
// ---------------------------------------------------------------------------
|
|
1519
|
+
|
|
1520
|
+
/** Resolve + validate a garden id and return its record file path under a store dir. */
|
|
1521
|
+
function recordFileFor(sessionsDir: string, gardenId: string): string {
|
|
1522
|
+
const id = requireGardenId(gardenId);
|
|
1523
|
+
return path.join(path.resolve(expandTilde(sessionsDir)), `${id}.meta.json`);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Read + parse a V1 meta-record by garden id, or throw if unknown (3D-4: renamed
|
|
1528
|
+
* from readMetaRecordByGardenId, demoted to v1-only — it uses the strict v1 parser
|
|
1529
|
+
* and throws on a v2 file). The live path reads identity via
|
|
1530
|
+
* readMetaIdentityByGardenId (dual-read); this stays for v1-fixture / dual-read
|
|
1531
|
+
* gates that need the raw v1 record (with delivery).
|
|
1532
|
+
*/
|
|
1533
|
+
export function readMetaRecordV1ByGardenId(
|
|
1534
|
+
gardenId: string,
|
|
1535
|
+
sessionsDir: string = defaultMetaSessionsDir(),
|
|
1536
|
+
): MetaRecord {
|
|
1537
|
+
const id = requireGardenId(gardenId);
|
|
1538
|
+
const file = recordFileFor(sessionsDir, id);
|
|
1539
|
+
if (!fs.existsSync(file)) {
|
|
1540
|
+
throw new MetaRecordError(
|
|
1541
|
+
`no meta-record for garden id "${id}" under ${path.dirname(file)} — not a garden citizen, cannot deliver.`,
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
const record = parseMetaRecord(fs.readFileSync(file, "utf8"));
|
|
1545
|
+
// The record BODY is the SSOT; the filename is only a denote-sortable surface.
|
|
1546
|
+
// A `<id>.meta.json` whose body carries a DIFFERENT gardenId is corrupt (a
|
|
1547
|
+
// renamed/clobbered file) and would misroute delivery — fail-fast, never trust
|
|
1548
|
+
// the filename over the body.
|
|
1549
|
+
if (record.gardenId !== id) {
|
|
1550
|
+
throw new MetaRecordError(
|
|
1551
|
+
`meta-record body/filename drift: ${id}.meta.json contains gardenId "${record.gardenId}". ` +
|
|
1552
|
+
`The body is the authority; this file is corrupt. Remove or fix it.`,
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
return record;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* The dual-read identity read-by-gardenId (0.11 Stage 0 step 3D-4 commit1,
|
|
1560
|
+
* additive). Same contract as readMetaRecordByGardenId — read the file, body is
|
|
1561
|
+
* SSOT, fail-fast on body/filename gardenId drift — but reads v1 AND v2 (via
|
|
1562
|
+
* parseMetaIdentity) and returns normalized identity. This is what the live path
|
|
1563
|
+
* uses (enqueue/read, the MCP sender-marker check) so it survives the v2 cut;
|
|
1564
|
+
* readMetaRecordV1ByGardenId remains the v1-only raw reader for v1-fixture gates.
|
|
1565
|
+
*/
|
|
1566
|
+
export function readMetaIdentityByGardenId(
|
|
1567
|
+
gardenId: string,
|
|
1568
|
+
sessionsDir: string = defaultMetaSessionsDir(),
|
|
1569
|
+
): MetaIdentity {
|
|
1570
|
+
const id = requireGardenId(gardenId);
|
|
1571
|
+
const file = recordFileFor(sessionsDir, id);
|
|
1572
|
+
if (!fs.existsSync(file)) {
|
|
1573
|
+
throw new MetaRecordError(
|
|
1574
|
+
`no meta-record for garden id "${id}" under ${path.dirname(file)} — not a garden citizen, cannot deliver.`,
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
const identity = parseMetaIdentity(fs.readFileSync(file, "utf8"));
|
|
1578
|
+
if (identity.gardenId !== id) {
|
|
1579
|
+
throw new MetaRecordError(
|
|
1580
|
+
`meta-record body/filename drift: ${id}.meta.json contains gardenId "${identity.gardenId}". ` +
|
|
1581
|
+
`The body is the authority; this file is corrupt. Remove or fix it.`,
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
return identity;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Probe-free existence check for a garden citizen's meta-record. Used by the 5d
|
|
1589
|
+
* entwurf_v2 production `resolveTarget`: a MISSING record is a soft `bad-target`
|
|
1590
|
+
* (identity:null), but a PRESENT-but-corrupt record must fail loud — so the producer
|
|
1591
|
+
* `existsSync`-checks here FIRST and only calls `readMetaIdentityByGardenId` when this
|
|
1592
|
+
* returns true, leaving drift/corruption as the lone throw (never matched by message
|
|
1593
|
+
* string). Validates the gid (F2-P1) like its read sibling.
|
|
1594
|
+
*/
|
|
1595
|
+
export function metaRecordExistsByGardenId(gardenId: string, sessionsDir: string = defaultMetaSessionsDir()): boolean {
|
|
1596
|
+
const id = requireGardenId(gardenId);
|
|
1597
|
+
return fs.existsSync(recordFileFor(sessionsDir, id));
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
export interface EnqueueMetaMessageOptions {
|
|
1601
|
+
gardenId: string;
|
|
1602
|
+
body: string;
|
|
1603
|
+
sessionsDir?: string;
|
|
1604
|
+
mailboxDir?: string;
|
|
1605
|
+
now?: Date;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
export interface EnqueueMetaMessageResult {
|
|
1609
|
+
gardenId: string;
|
|
1610
|
+
recordPath: string;
|
|
1611
|
+
messagePath: string;
|
|
1612
|
+
signalPath: string;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* Deliver a message body to a garden citizen's mailbox: validate the record
|
|
1617
|
+
* exists, write the `.msg` body FIRST, stamp `lastEnqueuedAt`, then poke
|
|
1618
|
+
* `inbox.signal` LAST so the doorbell that fires on the poke always finds the
|
|
1619
|
+
* body already on disk (no wake-with-empty-mailbox race). Returns the paths so a
|
|
1620
|
+
* sender can show exactly what was queued.
|
|
1621
|
+
*/
|
|
1622
|
+
export function enqueueMetaMessage(opts: EnqueueMetaMessageOptions): EnqueueMetaMessageResult {
|
|
1623
|
+
const now = opts.now ?? new Date();
|
|
1624
|
+
const sessionsDir = opts.sessionsDir ?? defaultMetaSessionsDir();
|
|
1625
|
+
const recordFile = recordFileFor(sessionsDir, opts.gardenId);
|
|
1626
|
+
// 3D-4: read IDENTITY (dual-read v1+v2) — confirms the citizen exists and
|
|
1627
|
+
// normalizes the gardenId. The record is no longer mutated; the v2 record carries
|
|
1628
|
+
// no delivery, so the enqueue receipt lives SOLELY in the mailbox state store.
|
|
1629
|
+
const citizen = readMetaIdentityByGardenId(opts.gardenId, sessionsDir);
|
|
1630
|
+
if (typeof opts.body !== "string" || opts.body.length === 0) {
|
|
1631
|
+
throw new MetaRecordError("enqueueMetaMessage: body must be a non-empty string.");
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const dir = path.join(path.resolve(expandTilde(opts.mailboxDir ?? defaultMetaMailboxDir())), citizen.gardenId);
|
|
1635
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1636
|
+
// Sortable + unique: ISO stamp (colons/dots flattened for a clean filename) +
|
|
1637
|
+
// a short random tag so two sends in the same millisecond never collide.
|
|
1638
|
+
const stamp = `${isoNow(now).replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}`;
|
|
1639
|
+
const messagePath = path.join(dir, `${stamp}.msg`);
|
|
1640
|
+
fs.writeFileSync(messagePath, opts.body, { mode: 0o600 });
|
|
1641
|
+
|
|
1642
|
+
// 3D-4 the cut: the enqueue receipt lives SOLELY in the mailbox state store now
|
|
1643
|
+
// (record.delivery removed from the v2 record). No record write. Stamped before the
|
|
1644
|
+
// signal poke so all state is settled before the watch fires. A state-stamp throw
|
|
1645
|
+
// surfaces fail-loud — no rollback: the `.msg` may already be written, but the
|
|
1646
|
+
// caller never gets a silent success.
|
|
1647
|
+
stampMailboxReceipt({
|
|
1648
|
+
gardenId: citizen.gardenId,
|
|
1649
|
+
mailboxDir: opts.mailboxDir ?? defaultMetaMailboxDir(),
|
|
1650
|
+
field: "lastEnqueuedAt",
|
|
1651
|
+
now,
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
// Poke LAST. Writing the timestamp changes the file's content+mtime, which is
|
|
1655
|
+
// what the plugin's FileChanged watch fires on.
|
|
1656
|
+
const signalPath = path.join(dir, "inbox.signal");
|
|
1657
|
+
fs.writeFileSync(signalPath, `${isoNow(now)}\n`, { mode: 0o600 });
|
|
1658
|
+
|
|
1659
|
+
return { gardenId: citizen.gardenId, recordPath: recordFile, messagePath, signalPath };
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
export interface MetaInboxMessage {
|
|
1663
|
+
file: string;
|
|
1664
|
+
body: string;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
export interface ReadMetaInboxOptions {
|
|
1668
|
+
gardenId: string;
|
|
1669
|
+
sessionsDir?: string;
|
|
1670
|
+
mailboxDir?: string;
|
|
1671
|
+
now?: Date;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
export interface ReadMetaInboxResult {
|
|
1675
|
+
gardenId: string;
|
|
1676
|
+
messages: MetaInboxMessage[];
|
|
1677
|
+
/** The D7 read-receipt timestamp stamped on this read, or null if nothing was unread. */
|
|
1678
|
+
readAt: string | null;
|
|
1679
|
+
recordPath: string;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Drain a garden citizen's mailbox: read every unread message (a fresh `.msg`
|
|
1684
|
+
* read before its doorbell, or a doorbell-rung `.msg.delivered`), archive each to
|
|
1685
|
+
* `*.read` so a re-read never double-returns, and — only if at least one message
|
|
1686
|
+
* was read — stamp `lastReadAt` (NOT `lastDeliveredAt`: the doorbell owns
|
|
1687
|
+
* delivery-time, see the stamp-site note below). An empty inbox mutates nothing:
|
|
1688
|
+
* reading nothing is not a receipt.
|
|
1689
|
+
*/
|
|
1690
|
+
export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
|
|
1691
|
+
const now = opts.now ?? new Date();
|
|
1692
|
+
const sessionsDir = opts.sessionsDir ?? defaultMetaSessionsDir();
|
|
1693
|
+
const recordFile = recordFileFor(sessionsDir, opts.gardenId);
|
|
1694
|
+
// 3D-4: read IDENTITY (dual-read) — citizen-existence + normalized gardenId. The
|
|
1695
|
+
// record is not mutated; the read receipt lives solely in the mailbox state store.
|
|
1696
|
+
const citizen = readMetaIdentityByGardenId(opts.gardenId, sessionsDir);
|
|
1697
|
+
|
|
1698
|
+
const dir = path.join(path.resolve(expandTilde(opts.mailboxDir ?? defaultMetaMailboxDir())), citizen.gardenId);
|
|
1699
|
+
const entries = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
|
|
1700
|
+
// Unread = a body still ending in .msg or .msg.delivered (NOT yet .read).
|
|
1701
|
+
const unread = entries.filter((f) => f.endsWith(".msg") || f.endsWith(".msg.delivered")).sort();
|
|
1702
|
+
|
|
1703
|
+
const messages: MetaInboxMessage[] = [];
|
|
1704
|
+
for (const f of unread) {
|
|
1705
|
+
const full = path.join(dir, f);
|
|
1706
|
+
messages.push({ file: f, body: fs.readFileSync(full, "utf8") });
|
|
1707
|
+
fs.renameSync(full, `${full}.read`); // archive; .read no longer matches the doorbell's *.msg glob
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (messages.length === 0) {
|
|
1711
|
+
return { gardenId: citizen.gardenId, messages, readAt: null, recordPath: recordFile };
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// 3D-4 the cut: the read receipt lives SOLELY in the mailbox state store now.
|
|
1715
|
+
// Stamp lastReadAt — the one receipt this layer stamps honestly (it KNOWS the body
|
|
1716
|
+
// reached the reader). lastDeliveredAt is the doorbell's to own; stamping it here
|
|
1717
|
+
// would report read-time as delivery-time, so it is left as the doorbell left it.
|
|
1718
|
+
// The state stamp returns the updated state, whose lastReadAt IS the D7 read-receipt.
|
|
1719
|
+
// Inside the messages.length>0 branch by construction — an empty inbox already
|
|
1720
|
+
// early-returned (no .read archive, state untouched), so "read nothing" is no
|
|
1721
|
+
// receipt on the state either. A throw surfaces fail-loud — no rollback: the
|
|
1722
|
+
// messages are already archived (.read), but the caller never gets a silent success.
|
|
1723
|
+
const state = stampMailboxReceipt({
|
|
1724
|
+
gardenId: citizen.gardenId,
|
|
1725
|
+
mailboxDir: opts.mailboxDir ?? defaultMetaMailboxDir(),
|
|
1726
|
+
field: "lastReadAt",
|
|
1727
|
+
now,
|
|
1728
|
+
});
|
|
1729
|
+
return { gardenId: citizen.gardenId, messages, readAt: state.lastReadAt, recordPath: recordFile };
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// ---------------------------------------------------------------------------
|
|
1733
|
+
// mailbox receipt state — the receipt authority's new home (0.11 Stage 0 3B)
|
|
1734
|
+
//
|
|
1735
|
+
// Today the read-receipt lives at `record.delivery.lastReadAt` (stamped by
|
|
1736
|
+
// readMetaInbox). v2 identity (step 3A) drops `delivery{}` out of the record, so
|
|
1737
|
+
// the receipt timestamps need a new home BEFORE that removal (NEXT.md 고정순서
|
|
1738
|
+
// 4: "delivery 제거 전 mailbox receipt state schema 먼저 못박음 ... 대체 state
|
|
1739
|
+
// 없이 제거 금지"). That home is `<meta-mailbox>/<gardenId>/state.json` — a
|
|
1740
|
+
// SIBLING of the inbox.signal/.msg traffic it accounts for, so the receipt lives
|
|
1741
|
+
// with the mailbox (volatile delivery bookkeeping), not with identity.
|
|
1742
|
+
//
|
|
1743
|
+
// This block is the SCHEMA + STORE only. It does NOT yet re-wire the live
|
|
1744
|
+
// enqueue/read path (that dual-write + the eventual record.delivery removal land
|
|
1745
|
+
// in step 3D, behind NEXT.md 끊을 지점 ②, so the "정당한 update vs regression"
|
|
1746
|
+
// gate-rewrite stays in one reviewed place). wakeMode/deliveryLevel are NOT here
|
|
1747
|
+
// — those are capability, not receipt (step 3C).
|
|
1748
|
+
// ---------------------------------------------------------------------------
|
|
1749
|
+
|
|
1750
|
+
/** Bump only on a breaking receipt-state shape change; the parser refuses other versions. */
|
|
1751
|
+
export const MAILBOX_RECEIPT_SCHEMA_VERSION = 1 as const;
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* The per-citizen mailbox receipt state. Holds exactly the three delivery
|
|
1755
|
+
* timestamps that move out of `record.delivery` (wakeMode/deliveryLevel are
|
|
1756
|
+
* capability, deliberately absent). Body is SSOT; the on-disk path is derived.
|
|
1757
|
+
*/
|
|
1758
|
+
export interface MailboxReceiptState {
|
|
1759
|
+
schemaVersion: typeof MAILBOX_RECEIPT_SCHEMA_VERSION;
|
|
1760
|
+
gardenId: string;
|
|
1761
|
+
lastEnqueuedAt: string | null;
|
|
1762
|
+
lastDeliveredAt: string | null;
|
|
1763
|
+
lastReadAt: string | null;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/** The receipt timestamp fields a mutator may stamp (runtime SSOT for validation). */
|
|
1767
|
+
export const MAILBOX_RECEIPT_FIELDS = ["lastEnqueuedAt", "lastDeliveredAt", "lastReadAt"] as const;
|
|
1768
|
+
export type MailboxReceiptField = (typeof MAILBOX_RECEIPT_FIELDS)[number];
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Validate an untrusted field name at runtime. The TS `MailboxReceiptField`
|
|
1772
|
+
* type does not survive a JS call site or an `as` cast — an invalid field would
|
|
1773
|
+
* otherwise create a stray key in memory that `serialize` silently drops. Crash
|
|
1774
|
+
* instead, mirroring the record layer's "crash, don't warn".
|
|
1775
|
+
*/
|
|
1776
|
+
function requireMailboxReceiptField(value: unknown): MailboxReceiptField {
|
|
1777
|
+
if (typeof value !== "string" || !MAILBOX_RECEIPT_FIELDS.includes(value as MailboxReceiptField)) {
|
|
1778
|
+
throw new MetaRecordError(
|
|
1779
|
+
`stampMailboxReceipt "field" must be one of ${MAILBOX_RECEIPT_FIELDS.join(" | ")} (got ${describe(value)}).`,
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
return value as MailboxReceiptField;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/** A fresh, never-touched receipt state for a citizen (all timestamps null). */
|
|
1786
|
+
export function emptyMailboxReceiptState(gardenId: string): MailboxReceiptState {
|
|
1787
|
+
return {
|
|
1788
|
+
schemaVersion: MAILBOX_RECEIPT_SCHEMA_VERSION,
|
|
1789
|
+
gardenId: requireGardenId(gardenId),
|
|
1790
|
+
lastEnqueuedAt: null,
|
|
1791
|
+
lastDeliveredAt: null,
|
|
1792
|
+
lastReadAt: null,
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/** Canonical serialization: stable key order, 2-space indent, trailing newline. */
|
|
1797
|
+
export function serializeMailboxReceiptState(state: MailboxReceiptState): string {
|
|
1798
|
+
const ordered = {
|
|
1799
|
+
schemaVersion: state.schemaVersion,
|
|
1800
|
+
gardenId: state.gardenId,
|
|
1801
|
+
lastEnqueuedAt: state.lastEnqueuedAt,
|
|
1802
|
+
lastDeliveredAt: state.lastDeliveredAt,
|
|
1803
|
+
lastReadAt: state.lastReadAt,
|
|
1804
|
+
};
|
|
1805
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
const MAILBOX_RECEIPT_KEYS: readonly string[] = [
|
|
1809
|
+
"schemaVersion",
|
|
1810
|
+
"gardenId",
|
|
1811
|
+
"lastEnqueuedAt",
|
|
1812
|
+
"lastDeliveredAt",
|
|
1813
|
+
"lastReadAt",
|
|
1814
|
+
];
|
|
1815
|
+
|
|
1816
|
+
/** Parse + fully validate untrusted JSON into a MailboxReceiptState. Throws on any drift. */
|
|
1817
|
+
export function parseMailboxReceiptState(json: string): MailboxReceiptState {
|
|
1818
|
+
let raw: unknown;
|
|
1819
|
+
try {
|
|
1820
|
+
raw = JSON.parse(json);
|
|
1821
|
+
} catch (err) {
|
|
1822
|
+
throw new MetaRecordError(
|
|
1823
|
+
`mailbox receipt state is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1827
|
+
throw new MetaRecordError(`mailbox receipt state must be a JSON object (got ${describe(raw)}).`);
|
|
1828
|
+
}
|
|
1829
|
+
const obj = raw as Record<string, unknown>;
|
|
1830
|
+
if (obj.schemaVersion !== MAILBOX_RECEIPT_SCHEMA_VERSION) {
|
|
1831
|
+
throw new MetaRecordError(
|
|
1832
|
+
`mailbox receipt state "schemaVersion" must be ${MAILBOX_RECEIPT_SCHEMA_VERSION} (got ${describe(obj.schemaVersion)}).`,
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
const stray = Object.keys(obj).filter((k) => !MAILBOX_RECEIPT_KEYS.includes(k));
|
|
1836
|
+
if (stray.length > 0) {
|
|
1837
|
+
throw new MetaRecordError(
|
|
1838
|
+
`mailbox receipt state carries unexpected key(s) ${stray.map((k) => `"${k}"`).join(", ")} ` +
|
|
1839
|
+
`(allowed: ${MAILBOX_RECEIPT_KEYS.join(", ")}).`,
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
return {
|
|
1843
|
+
schemaVersion: MAILBOX_RECEIPT_SCHEMA_VERSION,
|
|
1844
|
+
gardenId: requireGardenId(obj.gardenId),
|
|
1845
|
+
lastEnqueuedAt: requireNullableString(obj.lastEnqueuedAt, "lastEnqueuedAt"),
|
|
1846
|
+
lastDeliveredAt: requireNullableString(obj.lastDeliveredAt, "lastDeliveredAt"),
|
|
1847
|
+
lastReadAt: requireNullableString(obj.lastReadAt, "lastReadAt"),
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/** The on-disk receipt-state path for a citizen: `<mailbox>/<gardenId>/state.json`. */
|
|
1852
|
+
export function mailboxReceiptStatePath(mailboxDir: string, gardenId: string): string {
|
|
1853
|
+
return path.join(path.resolve(expandTilde(mailboxDir)), requireGardenId(gardenId), "state.json");
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
export interface MailboxReceiptOptions {
|
|
1857
|
+
gardenId: string;
|
|
1858
|
+
mailboxDir?: string;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* Read a citizen's receipt state from disk, or an empty state if none exists
|
|
1863
|
+
* yet. Reading-nothing is not an error — a citizen that has never had a receipt
|
|
1864
|
+
* stamped simply has all-null timestamps (parallel to readMetaInbox treating an
|
|
1865
|
+
* empty inbox as "no receipt", not a failure).
|
|
1866
|
+
*/
|
|
1867
|
+
export function readMailboxReceiptState(opts: MailboxReceiptOptions): MailboxReceiptState {
|
|
1868
|
+
const gardenId = requireGardenId(opts.gardenId);
|
|
1869
|
+
const file = mailboxReceiptStatePath(opts.mailboxDir ?? defaultMetaMailboxDir(), gardenId);
|
|
1870
|
+
if (!fs.existsSync(file)) return emptyMailboxReceiptState(gardenId);
|
|
1871
|
+
const state = parseMailboxReceiptState(fs.readFileSync(file, "utf8"));
|
|
1872
|
+
// Body is SSOT, and the body gardenId must agree with the path it was read
|
|
1873
|
+
// from — a state.json whose body claims a different citizen is corruption,
|
|
1874
|
+
// fail-fast (parallel to readMetaRecordByGardenId's body/filename drift rule).
|
|
1875
|
+
if (state.gardenId !== gardenId) {
|
|
1876
|
+
throw new MetaRecordError(
|
|
1877
|
+
`mailbox receipt state body/path gardenId drift — body gardenId=${state.gardenId}, read from <mailbox>/${gardenId}/state.json.`,
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
return state;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* Stamp ONE receipt field to `now` and atomically persist the state (read-
|
|
1885
|
+
* modify-write; creates the state on first stamp). Returns the updated state.
|
|
1886
|
+
* The atomic tmp+rename mirrors atomicWriteRecord so a concurrent reader never
|
|
1887
|
+
* observes a half-written state.json.
|
|
1888
|
+
*/
|
|
1889
|
+
export function stampMailboxReceipt(
|
|
1890
|
+
opts: MailboxReceiptOptions & { field: MailboxReceiptField; now?: Date },
|
|
1891
|
+
): MailboxReceiptState {
|
|
1892
|
+
const now = opts.now ?? new Date();
|
|
1893
|
+
const field = requireMailboxReceiptField(opts.field);
|
|
1894
|
+
const file = mailboxReceiptStatePath(opts.mailboxDir ?? defaultMetaMailboxDir(), opts.gardenId);
|
|
1895
|
+
const current = readMailboxReceiptState(opts);
|
|
1896
|
+
const updated: MailboxReceiptState = { ...current, [field]: isoNow(now) };
|
|
1897
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1898
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1899
|
+
fs.writeFileSync(tmp, serializeMailboxReceiptState(updated), { mode: 0o600 });
|
|
1900
|
+
fs.renameSync(tmp, file);
|
|
1901
|
+
return updated;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/** The v1 delivery receipt timestamps that migrate to mailbox state (the 3 only — wakeMode/deliveryLevel are capability). */
|
|
1905
|
+
export interface V1DeliveryReceipts {
|
|
1906
|
+
lastEnqueuedAt: string | null;
|
|
1907
|
+
lastDeliveredAt: string | null;
|
|
1908
|
+
lastReadAt: string | null;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* Migrate a v1 record's delivery receipts into the mailbox state store (3D-4),
|
|
1913
|
+
* called by upsert BEFORE it rewrites a v1 file as v2 so a pre-3D-2 receipt is not
|
|
1914
|
+
* lost. Per-field merge, STATE WINS: a v1 timestamp only fills a state field that
|
|
1915
|
+
* is still null (`state[f] ?? v1[f]`); a state value already there is never
|
|
1916
|
+
* overwritten. ONLY the 3 timestamps move — wakeMode/deliveryLevel are capability
|
|
1917
|
+
* (registry), and a stray key would trip the receipt-state strict keyset (H2).
|
|
1918
|
+
*
|
|
1919
|
+
* "Migrating nothing is not a receipt": if no v1 value fills a null state field
|
|
1920
|
+
* (state already wins on every field, or v1 had nothing), this is a NO-OP — no
|
|
1921
|
+
* write, no state.json creation — returning null. Otherwise it writes the merged
|
|
1922
|
+
* state atomically (tmp+rename, mirroring stampMailboxReceipt) and returns it.
|
|
1923
|
+
*/
|
|
1924
|
+
export function migrateV1DeliveryReceipts(opts: {
|
|
1925
|
+
gardenId: string;
|
|
1926
|
+
delivery: V1DeliveryReceipts;
|
|
1927
|
+
mailboxDir?: string;
|
|
1928
|
+
}): MailboxReceiptState | null {
|
|
1929
|
+
const gardenId = requireGardenId(opts.gardenId);
|
|
1930
|
+
const mailboxDir = opts.mailboxDir ?? defaultMetaMailboxDir();
|
|
1931
|
+
const current = readMailboxReceiptState({ gardenId, mailboxDir });
|
|
1932
|
+
const merged: MailboxReceiptState = {
|
|
1933
|
+
...current,
|
|
1934
|
+
lastEnqueuedAt: current.lastEnqueuedAt ?? opts.delivery.lastEnqueuedAt,
|
|
1935
|
+
lastDeliveredAt: current.lastDeliveredAt ?? opts.delivery.lastDeliveredAt,
|
|
1936
|
+
lastReadAt: current.lastReadAt ?? opts.delivery.lastReadAt,
|
|
1937
|
+
};
|
|
1938
|
+
if (
|
|
1939
|
+
merged.lastEnqueuedAt === current.lastEnqueuedAt &&
|
|
1940
|
+
merged.lastDeliveredAt === current.lastDeliveredAt &&
|
|
1941
|
+
merged.lastReadAt === current.lastReadAt
|
|
1942
|
+
) {
|
|
1943
|
+
return null; // no-write / no-create — migrating nothing is not a receipt
|
|
1944
|
+
}
|
|
1945
|
+
const file = mailboxReceiptStatePath(mailboxDir, gardenId);
|
|
1946
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1947
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1948
|
+
fs.writeFileSync(tmp, serializeMailboxReceiptState(merged), { mode: 0o600 });
|
|
1949
|
+
fs.renameSync(tmp, file);
|
|
1950
|
+
return merged;
|
|
1951
|
+
}
|