@adhdev/mesh-shared 1.0.60-rc.7 → 1.0.60-rc.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/brain-routing.d.ts +2 -2
  2. package/dist/index.d.ts +12 -0
  3. package/dist/index.js +3264 -35
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2931 -35
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/keyed-doc-delta.d.ts +83 -0
  8. package/dist/keyed-status-delta.d.ts +76 -0
  9. package/dist/magi.d.ts +1 -1
  10. package/dist/mesh-tool-names.d.ts +27 -2
  11. package/dist/mesh-vocabulary.d.ts +70 -0
  12. package/dist/mission-brief.d.ts +62 -0
  13. package/dist/outbound-message.d.ts +202 -0
  14. package/dist/path-ownership.d.ts +101 -0
  15. package/dist/protocol/daemon-server.d.ts +381 -0
  16. package/dist/protocol/dashboard-daemon-p2p.d.ts +216 -0
  17. package/dist/protocol/dashboard-server.d.ts +240 -0
  18. package/dist/protocol/envelope.d.ts +55 -0
  19. package/dist/protocol/index.d.ts +15 -0
  20. package/dist/session-launch.d.ts +113 -0
  21. package/dist/session-status.d.ts +61 -0
  22. package/dist/slot-proposal.d.ts +1 -1
  23. package/dist/turn-evidence.d.ts +438 -0
  24. package/dist/turn-ipc.d.ts +1040 -0
  25. package/dist/types.d.ts +11 -0
  26. package/dist/worker-protocol.d.ts +69 -0
  27. package/dist/ws-protocol.d.ts +71 -41
  28. package/package.json +1 -1
  29. package/src/brain-routing.ts +3 -3
  30. package/src/index.ts +12 -0
  31. package/src/keyed-doc-delta.ts +348 -0
  32. package/src/keyed-status-delta.ts +239 -0
  33. package/src/magi.ts +1 -1
  34. package/src/mesh-tool-names.ts +65 -19
  35. package/src/mesh-vocabulary.ts +98 -0
  36. package/src/mission-brief.ts +153 -0
  37. package/src/outbound-message.ts +320 -0
  38. package/src/path-ownership.ts +271 -0
  39. package/src/protocol/daemon-server.ts +545 -0
  40. package/src/protocol/dashboard-daemon-p2p.ts +280 -0
  41. package/src/protocol/dashboard-server.ts +340 -0
  42. package/src/protocol/envelope.ts +75 -0
  43. package/src/protocol/index.ts +16 -0
  44. package/src/session-launch.ts +247 -0
  45. package/src/session-status.ts +140 -0
  46. package/src/slot-proposal.ts +1 -1
  47. package/src/turn-evidence.ts +602 -0
  48. package/src/turn-ipc.ts +2331 -0
  49. package/src/types.ts +8 -0
  50. package/src/worker-protocol.ts +164 -0
  51. package/src/ws-protocol.ts +102 -93
package/dist/types.d.ts CHANGED
@@ -136,4 +136,15 @@ export interface RepoMeshSessionStatus {
136
136
  startedAt?: string | null;
137
137
  lastActivityAt?: string | null;
138
138
  isCached?: boolean;
139
+ /**
140
+ * Worker MCP delivery outcome for this session's launch (present only for a
141
+ * mesh-delegated launch). `reason` is a short enum code, never free text —
142
+ * see `WORKER_MCP_DELIVERY_REASONS` (daemon-core's `mesh/worker-mcp-isolation.ts`,
143
+ * the source of the vocabulary this field's values are drawn from; not
144
+ * re-exported here so mesh-shared stays a dependency-free leaf).
145
+ */
146
+ workerMcp?: {
147
+ delivered: boolean;
148
+ reason?: string;
149
+ };
139
150
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * worker-protocol — what a delegated worker is told, and what it may call.
3
+ *
4
+ * Wiring-unification Phase F1 (docs/design/2026-09-23-wiring-unification.md §7b).
5
+ *
6
+ * Measured 2026-09-23 on the live preview ledger: 635 worker attempts in 14
7
+ * days, 3 `report_completion` calls. The worker MCP was delivered to every
8
+ * provider and never used, because nothing ever told the worker it existed —
9
+ * the dispatched task body carried no instruction and the coordinator prompt
10
+ * did not mention the worker tools at all. The only text a worker saw about
11
+ * its tools was the tool description.
12
+ *
13
+ * This module is the single source for BOTH sides of that contract:
14
+ * - `WORKER_TOOLS` — exactly what `adhdev mcp --mode worker` advertises
15
+ * (a parity test in mcp-server pins ListTools to this tuple);
16
+ * - `renderWorkerProtocolFooter()` — the block appended to EVERY dispatched
17
+ * task body, so a worker learns the protocol from the task itself;
18
+ * - `renderCoordinatorWorkerSection()` — the matching paragraph the
19
+ * coordinator prompt carries, so the coordinator expects structured reports
20
+ * and stops polling for them.
21
+ *
22
+ * Wiring-unification Phase H2 adds an optional mission-brief block: when a
23
+ * dispatched task belongs to a mission that carries a `MissionBrief`
24
+ * (`./mission-brief.ts`), `renderWorkerProtocolFooter` renders it ABOVE the
25
+ * `WORKER_PROTOCOL_FOOTER_MARKER` line. This is deliberate placement, not an
26
+ * accident of insertion order: `hasWorkerProtocolFooter` / `stripWorkerProtocolFooter`
27
+ * both key off the marker line alone, so a body with a brief block strips
28
+ * identically to one without — the brief is authored content from the
29
+ * coordinator's point of view (like the task message itself), never part of
30
+ * the protocol contract the marker demarcates.
31
+ */
32
+ import { type MissionBrief } from './mission-brief';
33
+ export declare const WORKER_TOOLS: readonly ["report_completion", "progress_update", "peer_context_pull", "git_status", "git_log", "git_diff"];
34
+ export type WorkerTool = typeof WORKER_TOOLS[number];
35
+ export declare function isWorkerTool(value: unknown): value is WorkerTool;
36
+ /** Stable marker line so a body is never footered twice and tests can find it. */
37
+ export declare const WORKER_PROTOCOL_FOOTER_MARKER = "--- adhdev worker protocol ---";
38
+ export interface WorkerProtocolFooterInput {
39
+ taskId?: string;
40
+ taskMode?: string;
41
+ difficulty?: string;
42
+ readonly?: boolean;
43
+ /** Number of handoff notes enclosed above the footer, when any. */
44
+ enclosedHandoffNotes?: number;
45
+ /** H2: the owning mission's brief, when this task belongs to one. Rendered above the marker line — see module doc. */
46
+ missionBrief?: MissionBrief;
47
+ }
48
+ export declare function hasWorkerProtocolFooter(body: string): boolean;
49
+ /**
50
+ * The protocol block appended to a dispatched task. Written for the model that
51
+ * receives it: imperative, short, and explicit about what is NOT available so
52
+ * the worker does not go looking for coordinator tools.
53
+ */
54
+ export declare function renderWorkerProtocolFooter(input?: WorkerProtocolFooterInput): string;
55
+ /**
56
+ * The authored text of a dispatched body: everything before the footer marker.
57
+ * Ledger descriptors, replay fixtures and tests compare authored text, never
58
+ * the protocol block.
59
+ */
60
+ export declare function stripWorkerProtocolFooter(body: string): string;
61
+ /**
62
+ * Append the footer to a body that does not carry one yet. Idempotent.
63
+ */
64
+ export declare function appendWorkerProtocolFooter(body: string, input?: WorkerProtocolFooterInput): string;
65
+ /**
66
+ * The paragraph the COORDINATOR prompt carries about its workers. Kept next to
67
+ * the footer so the two halves of the protocol cannot drift apart.
68
+ */
69
+ export declare function renderCoordinatorWorkerSection(): string;
@@ -1,50 +1,80 @@
1
1
  /**
2
- * ws-protocol — shared string-literal unions for the daemon↔server WS surface
3
- * and the daemon↔dashboard P2P DataChannel surface.
2
+ * ws-protocol — name-only aliases for the daemon↔server WS surface and the
3
+ * daemon↔dashboard P2P DataChannel surface, plus the `auth_ok.payload.limits`
4
+ * contract.
4
5
  *
5
- * Fragmentation audit: these message types existed as a TypeScript union in
6
- * exactly ONE package (the proprietary daemon-cloud's server-connection.ts),
7
- * which is a leaf CONSUMER — the other two participants (the Workers server
8
- * and OSS daemon-core, which is the primary `status_report` producer) matched
9
- * bare string literals by hand. Renaming `auth_ok` server-side would compile
10
- * everywhere and leave the daemon reconnecting forever. Pure literals, zero
11
- * runtime deps — the textbook mesh-shared leaf.
12
- *
13
- * SCOPE HONESTY: this file declares the OSS-visible protocol surface. The
14
- * proprietary repo's server-connection.ts remains the authority for the full
15
- * ServerToDaemon command set; it should adopt these unions and extend them
16
- * (`ServerToDaemonMsg | <proprietary extras>`) rather than re-declaring the
17
- * shared members. Members here are the ones OSS daemon-core itself produces
18
- * or matches.
6
+ * Wiring-unification Phase A2: the typed frame unions now live in ./protocol
7
+ * (one file per link, payloads included). This file keeps the symbols that
8
+ * pre-dated them — `DaemonToServerWsMsg`, `ServerToDaemonWsMsg`,
9
+ * `P2PSignalingWsMsg`, `DashboardP2PMessageKind` and the two name arrays —
10
+ * DERIVED from the protocol unions rather than re-declared, so they cannot
11
+ * drift again. Before this derivation the list here said `offer|answer|ice`
12
+ * while the wire said `p2p_offer|p2p_answer|p2p_ice`, declared a `log` frame
13
+ * nothing sent or handled, omitted `daemon_mesh_command` (sent by a raw
14
+ * `ws.send`), and listed 12 of the ~27 P2P kinds — and nothing imported the
15
+ * drifted members, so nothing noticed.
19
16
  */
20
- /** Messages the daemon sends UP to the Workers server over the WS bridge. */
21
- export type DaemonToServerWsMsg = 'auth' | 'status_report' | 'status_heartbeat' | 'status_event' | 'command_result' | 'error' | 'agent_event' | 'log'
17
+ import { type DaemonToServerType, type ServerToDaemonControlType, type P2PSignalType, type MeshP2PSignalType, type DashboardToDaemonP2PType, type DaemonToDashboardP2PType } from './protocol';
18
+ /** Messages the daemon sends UP to the Workers server over the WS bridge (see protocol/daemon-server.ts). */
19
+ export type DaemonToServerWsMsg = DaemonToServerType;
22
20
  /**
23
- * seqscribe Beacon vectors (design §7.1). ONE daemon-initiated frame carries
24
- * both directions — `op: 'put'` stores this node's content-free vector
25
- * report, `op: 'get'` asks for the board — because the server has no way to
26
- * wake itself: `DaemonConnectionDO` has neither an alarm nor a timer, and
27
- * adding one would hit the most request-quota-pressured axis in the system.
21
+ * Server→daemon CONTROL messages (see protocol/daemon-server.ts). The server
22
+ * additionally relays an open command namespace as the frame `type`
23
+ * (`ServerDirectCommandMsg`), which is deliberately not a member here.
28
24
  */
29
- | 'beacon_vectors';
30
- /** Server→daemon control messages the OSS engine reacts to. */
31
- export type ServerToDaemonWsMsg = 'auth_ok' | 'auth_error' | 'machine_evicted' | 'force_disconnect' | 'token_revoked' | 'version_mismatch' | 'force_update_required' | 'command' | 'agent_command' | 'resolve_action'
25
+ export type ServerToDaemonWsMsg = ServerToDaemonControlType;
26
+ /** P2P signaling relayed through the server WS — the `p2p_`/`mesh_p2p_` spellings the wire actually uses. */
27
+ export type P2PSignalingWsMsg = P2PSignalType | MeshP2PSignalType;
28
+ /** Every dashboard↔daemon P2P DataChannel JSON kind, both directions (see protocol/dashboard-daemon-p2p.ts). */
29
+ export type DashboardP2PMessageKind = DashboardToDaemonP2PType | DaemonToDashboardP2PType;
30
+ export declare const DAEMON_TO_SERVER_WS_MSGS: readonly DaemonToServerWsMsg[];
31
+ export declare const SERVER_TO_DAEMON_WS_MSGS: readonly ServerToDaemonWsMsg[];
32
+ export declare const isDaemonToServerWsMsg: (value: unknown) => value is DaemonToServerWsMsg;
33
+ export declare const isServerToDaemonWsMsg: (value: unknown) => value is ServerToDaemonWsMsg;
32
34
  /**
33
- * Reply to a `beacon_vectors` GET, correlated by `requestId`. A PUT is
34
- * fire-and-forget and gets no reply at all — the beacon is advisory, so a
35
- * lost report costs one debounce cycle of prediction accuracy and nothing
36
- * else, which is not worth an ack round trip.
35
+ * `auth_ok.payload.limits` — the plan-limit contract the server sends DOWN to
36
+ * the daemon for client-side enforcement.
37
+ *
38
+ * WHY THIS EXISTS: the wire spelling is `maxP2Pconnections` (lowercase `c`),
39
+ * inherited from `PlanLimits` in the proprietary server's plan-limits.ts. The
40
+ * daemon-cloud consumer independently declared the same field as
41
+ * `maxP2PConnections` (capital `C`) and assigned it from a `payload as any`
42
+ * cast — so the two spellings NEVER met under a type, TypeScript had nothing
43
+ * to compare, and the limit arrived as `undefined` at every comparison site.
44
+ * `undefined !== -1` passes the "is it unlimited?" guard, then every
45
+ * `count >= undefined` is false: enforcement silently degraded to unlimited on
46
+ * all plans, in production, for months.
47
+ *
48
+ * The lesson is NOT "pick a spelling" — it is that a cross-package wire
49
+ * contract asserted by hand on both ends with an `any` in between cannot fail
50
+ * loudly. Both producer (server) and consumer (daemon-cloud) must now import
51
+ * THIS symbol, so a rename breaks the build on both sides instead of silently
52
+ * disabling a paid-plan limit. Use `normalizeAuthOkLimits()` at the parse
53
+ * boundary; never re-spell these fields locally.
37
54
  */
38
- | 'beacon_vectors_result';
39
- /** P2P signaling relayed through the server WS. */
40
- export type P2PSignalingWsMsg = 'p2p_ready' | 'offer' | 'answer' | 'ice' | 'mesh_p2p_ready' | 'mesh_p2p_offer' | 'mesh_p2p_answer' | 'mesh_p2p_ice';
55
+ export interface AuthOkPlanLimits {
56
+ /** P2P concurrent connection count (-1 = unlimited). WIRE SPELLING — lowercase `c`. */
57
+ maxP2Pconnections: number;
58
+ /** Screenshot send interval floor (seconds). 0 = real-time. */
59
+ screenshotIntervalSeconds: number;
60
+ /** Daily screenshot usage budget (minutes). -1 = unlimited. */
61
+ dailyScreenshotMinutes: number;
62
+ /** Max connectable machines/daemons (-1 = unlimited). */
63
+ maxMachines: number;
64
+ }
41
65
  /**
42
- * Dashboard↔daemon P2P DataChannel JSON message kinds. Previously matched as
43
- * hand-synced literals on both ends with NO shared symbol anywhere —
44
- * `p2p_evicted` had exactly two occurrences repo-wide (emit + handle).
66
+ * Parse-boundary normalizer for `auth_ok.payload.limits`.
67
+ *
68
+ * Returns `null` when the payload carries no usable limits object, so callers
69
+ * keep their existing "no limits known yet" branch. Individual non-numeric or
70
+ * missing fields fall back to the UNLIMITED sentinel (-1) for count-style
71
+ * limits and 0 for the interval floor, matching how each consumer already
72
+ * treats "no constraint" — a malformed field must never be coerced to a
73
+ * MORE restrictive value than the server intended, or a server-side schema
74
+ * change would start throttling paying users.
75
+ *
76
+ * Accepts the capital-`C` misspelling as an input alias ONLY so a daemon
77
+ * talking to a mixed-version fleet cannot regress; output is always the
78
+ * canonical wire spelling.
45
79
  */
46
- export type DashboardP2PMessageKind = 'ping' | 'pong' | 'status_report' | 'status_event' | 'p2p_evicted' | 'command' | 'command_result' | 'command_result_chunk' | 'screenshot_start' | 'screenshot_stop' | 'pty_input' | 'pty_resize';
47
- export declare const DAEMON_TO_SERVER_WS_MSGS: readonly DaemonToServerWsMsg[];
48
- export declare const SERVER_TO_DAEMON_WS_MSGS: readonly ServerToDaemonWsMsg[];
49
- export declare function isDaemonToServerWsMsg(value: unknown): value is DaemonToServerWsMsg;
50
- export declare function isServerToDaemonWsMsg(value: unknown): value is ServerToDaemonWsMsg;
80
+ export declare function normalizeAuthOkLimits(raw: unknown): AuthOkPlanLimits | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/mesh-shared",
3
- "version": "1.0.60-rc.7",
3
+ "version": "1.0.60-rc.70",
4
4
  "description": "ADHDev mesh-shared — pure mesh/git status normalizers shared by daemon-core and web-core",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -66,7 +66,7 @@ export function isMeshTaskDifficulty(value: unknown): value is MeshTaskDifficult
66
66
  * a value that should never have been stamped provider-blind.
67
67
  *
68
68
  * 2. IT FOUGHT THE SLOTS. Operators express model choice per node via
69
- * capability slots (`mesh_node_slots_set`), which know the provider. A
69
+ * capability slots (`mesh_node_slots` action "set"), which know the provider. A
70
70
  * mesh-wide preset that also picks a model duplicated that decision from a
71
71
  * position with strictly less information, and needed the modelSource
72
72
  * ('explicit' vs 'preset') precedence machinery plus a fail-closed
@@ -112,7 +112,7 @@ export function normalizeDifficultyBrainMap(raw: unknown): DifficultyBrainMap {
112
112
  }
113
113
 
114
114
  // ─────────────────────────────────────────────────────────────────────────────
115
- // Node capability slots (ORCHESTRATION_NODE_SLOTS.md)
115
+ // Node capability slots (node capability slots design, 2026-07-09)
116
116
  //
117
117
  // A node's "Preferred AI tools" list is redefined as an ordered array of
118
118
  // capability slots. Each slot bundles what used to be scattered across
@@ -252,7 +252,7 @@ export function deriveSlotsFromLegacy(input: {
252
252
  * The reverse direction of deriveSlotsFromLegacy: derive the legacy
253
253
  * `providerPriority` order from capability slots — each slot's provider, in
254
254
  * first-appearance order, de-duplicated. Slot order = preference is the settled
255
- * slots semantics (ORCHESTRATION_NODE_SLOTS.md), so this is what readers of the
255
+ * slots semantics (node capability slots design, 2026-07-09), so this is what readers of the
256
256
  * legacy `policy.providerPriority` field must fall back to when a node declares
257
257
  * slots but no explicit providerPriority (otherwise such a node reads as
258
258
  * unlaunchable even though its slots fully determine the preference order).
package/src/index.ts CHANGED
@@ -29,3 +29,15 @@ export * from './rpc-chunking'
29
29
  export * from './semver-compare'
30
30
  export * from './ws-protocol'
31
31
  export * from './interactive-prompt-constants'
32
+ export * from './session-status'
33
+ export * from './mesh-vocabulary'
34
+ export * from './outbound-message'
35
+ export * from './worker-protocol'
36
+ export * from './protocol'
37
+ export * from './turn-evidence'
38
+ export * from './session-launch'
39
+ export * from './turn-ipc'
40
+ export * from './path-ownership'
41
+ export * from './mission-brief'
42
+ export * from './keyed-status-delta'
43
+ export * from './keyed-doc-delta'
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Keyed document delta — the diff/fold pair behind the `mesh.status` lane
3
+ * (the dashboard's mesh view; data-path audit 2026-09-29 P1-5).
4
+ *
5
+ * A document is plain top-level fields plus named keyed COLLECTIONS (arrays of
6
+ * rows identified by an id field, possibly nested one object deep, e.g.
7
+ * `queue.tasks`). The daemon digests every body it would have sent, diffs it
8
+ * against the digest of the last body DELIVERED to a subscription, and sends
9
+ * only the difference: changed top-level fields, and per collection the rows
10
+ * whose content changed (changed fields only), the fields a row lost, and the
11
+ * rows that disappeared. An unchanged document diffs to `null` — zero bytes on
12
+ * the wire. The dashboard folds each delta into its held snapshot; the fold of
13
+ * every delta since a snapshot equals the latest snapshot (pinned by
14
+ * keyed-doc-delta.test.ts).
15
+ *
16
+ * `volatileTopLevel` fields (a build stamp, live process counters) are not a
17
+ * change by themselves; when something else changed they ride along.
18
+ *
19
+ * Pure leaf: plain objects in, plain objects out; no transport, no clock.
20
+ */
21
+
22
+ export interface KeyedDocSpec {
23
+ /** Collection path (`nodes`, `queue.tasks`) → the id field of its rows. */
24
+ collections: Readonly<Record<string, string>>
25
+ /** Top-level fields that change on every build without meaning anything. */
26
+ volatileTopLevel?: readonly string[]
27
+ /**
28
+ * The part of a collection row that decides whether it CHANGED (drop the
29
+ * per-build stamps inside it). A row whose stable view is unchanged sends
30
+ * nothing; a changed field is sent with its full current value, stamps
31
+ * included. Must not mutate `row`.
32
+ */
33
+ stableRow?: (collection: string, row: Record<string, unknown>) => Record<string, unknown>
34
+ }
35
+
36
+ type Row = Record<string, unknown>
37
+
38
+ interface RowDigest {
39
+ readonly value: Row
40
+ /** The row as compared (stableRow view). */
41
+ readonly stable: Row
42
+ readonly sig: string
43
+ fieldSigs?: Map<string, string>
44
+ }
45
+
46
+ interface CollectionDigest {
47
+ readonly rows: ReadonlyMap<string, RowDigest>
48
+ readonly order: readonly string[]
49
+ }
50
+
51
+ export interface KeyedDocDigest {
52
+ readonly top: ReadonlyMap<string, string>
53
+ readonly topValues: Readonly<Record<string, unknown>>
54
+ readonly collections: ReadonlyMap<string, CollectionDigest>
55
+ readonly volatile: ReadonlySet<string>
56
+ }
57
+
58
+ export interface KeyedCollectionDelta {
59
+ /** New rows (whole) and changed rows (id + changed fields). */
60
+ upsert?: Row[]
61
+ /** Fields a row lost, by row id. */
62
+ unsetFields?: Record<string, string[]>
63
+ removed?: string[]
64
+ order?: string[]
65
+ /** The collection is no longer present in the document at all. */
66
+ absent?: true
67
+ }
68
+
69
+ export interface KeyedDocDelta {
70
+ set?: Record<string, unknown>
71
+ unset?: string[]
72
+ collections?: Record<string, KeyedCollectionDelta>
73
+ }
74
+
75
+ function sig(value: unknown): string | undefined {
76
+ if (value === undefined) return undefined
77
+ try {
78
+ return JSON.stringify(value)
79
+ } catch {
80
+ return undefined
81
+ }
82
+ }
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return !!value && typeof value === 'object' && !Array.isArray(value)
86
+ }
87
+
88
+ /** Read the array at `path` (`a` or `a.b`) — undefined when absent. */
89
+ function readPath(doc: Record<string, unknown>, path: string): unknown {
90
+ const [head, tail] = splitPath(path)
91
+ if (!tail) return doc[head]
92
+ const parent = doc[head]
93
+ return isRecord(parent) ? parent[tail] : undefined
94
+ }
95
+
96
+ function splitPath(path: string): [string, string | undefined] {
97
+ const dot = path.indexOf('.')
98
+ return dot < 0 ? [path, undefined] : [path.slice(0, dot), path.slice(dot + 1)]
99
+ }
100
+
101
+ /**
102
+ * The document minus its collections (a nested collection is removed from a
103
+ * shallow copy of its parent, so the parent's other fields stay top-level data).
104
+ */
105
+ function stripCollections(doc: Record<string, unknown>, spec: KeyedDocSpec): Record<string, unknown> {
106
+ const out: Record<string, unknown> = { ...doc }
107
+ for (const path of Object.keys(spec.collections)) {
108
+ const [head, tail] = splitPath(path)
109
+ if (!tail) {
110
+ delete out[head]
111
+ continue
112
+ }
113
+ const parent = out[head]
114
+ if (!isRecord(parent) || !(tail in parent)) continue
115
+ const copy = { ...parent }
116
+ delete copy[tail]
117
+ out[head] = copy
118
+ }
119
+ return out
120
+ }
121
+
122
+ function fieldSigsOf(digest: RowDigest): Map<string, string> {
123
+ if (!digest.fieldSigs) {
124
+ const sigs = new Map<string, string>()
125
+ for (const [key, value] of Object.entries(digest.stable)) {
126
+ const s = sig(value)
127
+ if (s !== undefined) sigs.set(key, s)
128
+ }
129
+ digest.fieldSigs = sigs
130
+ }
131
+ return digest.fieldSigs
132
+ }
133
+
134
+ /** Digest one document. Cheap to hold: values are referenced, not copied. */
135
+ export function digestKeyedDoc(doc: Record<string, unknown>, spec: KeyedDocSpec): KeyedDocDigest {
136
+ const top = new Map<string, string>()
137
+ const topValues: Record<string, unknown> = {}
138
+ for (const [key, value] of Object.entries(stripCollections(doc, spec))) {
139
+ const s = sig(value)
140
+ if (s === undefined) continue
141
+ top.set(key, s)
142
+ topValues[key] = value
143
+ }
144
+ const collections = new Map<string, CollectionDigest>()
145
+ for (const [path, idField] of Object.entries(spec.collections)) {
146
+ const list = readPath(doc, path)
147
+ if (!Array.isArray(list)) continue
148
+ const rows = new Map<string, RowDigest>()
149
+ const order: string[] = []
150
+ for (const row of list) {
151
+ if (!isRecord(row)) continue
152
+ const id = row[idField]
153
+ if (typeof id !== 'string' || !id || rows.has(id)) continue
154
+ const stable = spec.stableRow ? spec.stableRow(path, row) : row
155
+ rows.set(id, { value: row, stable, sig: sig(stable) ?? '' })
156
+ order.push(id)
157
+ }
158
+ collections.set(path, { rows, order })
159
+ }
160
+ return { top, topValues, collections, volatile: new Set(spec.volatileTopLevel ?? []) }
161
+ }
162
+
163
+ function sameOrder(a: readonly string[], b: readonly string[]): boolean {
164
+ if (a.length !== b.length) return false
165
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
166
+ return true
167
+ }
168
+
169
+ function diffCollection(prev: CollectionDigest | undefined, next: CollectionDigest | undefined, idField: string): KeyedCollectionDelta | null {
170
+ if (!next) return prev ? { absent: true } : null
171
+ const delta: KeyedCollectionDelta = {}
172
+ const prevRows = prev?.rows ?? new Map<string, RowDigest>()
173
+ const nextRows = next?.rows ?? new Map<string, RowDigest>()
174
+ for (const [id, nextRow] of nextRows) {
175
+ const prevRow = prevRows.get(id)
176
+ if (!prevRow) {
177
+ ;(delta.upsert ??= []).push(nextRow.value)
178
+ continue
179
+ }
180
+ if (prevRow.sig === nextRow.sig) continue
181
+ const prevSigs = fieldSigsOf(prevRow)
182
+ const nextSigs = fieldSigsOf(nextRow)
183
+ const change: Row = { [idField]: id }
184
+ let changed = false
185
+ for (const [key, s] of nextSigs) {
186
+ if (key === idField || prevSigs.get(key) === s) continue
187
+ change[key] = nextRow.value[key]
188
+ changed = true
189
+ }
190
+ const gone: string[] = []
191
+ for (const key of prevSigs.keys()) if (!nextSigs.has(key) && !(key in nextRow.value)) gone.push(key)
192
+ if (changed) (delta.upsert ??= []).push(change)
193
+ if (gone.length > 0) (delta.unsetFields ??= {})[id] = gone
194
+ }
195
+ for (const id of prevRows.keys()) {
196
+ if (!nextRows.has(id)) (delta.removed ??= []).push(id)
197
+ }
198
+ if (!sameOrder(prev?.order ?? [], next?.order ?? [])) delta.order = [...(next?.order ?? [])]
199
+ return Object.keys(delta).length > 0 ? delta : null
200
+ }
201
+
202
+ /**
203
+ * The difference `prev → next`, or `null` when nothing a consumer can observe
204
+ * changed (volatile top-level fields alone are not a change).
205
+ */
206
+ export function diffKeyedDoc(prev: KeyedDocDigest, next: KeyedDocDigest, spec: KeyedDocSpec): KeyedDocDelta | null {
207
+ const delta: KeyedDocDelta = {}
208
+ let meaningful = false
209
+ let set: Record<string, unknown> | undefined
210
+ let unset: string[] | undefined
211
+ for (const [key, s] of next.top) {
212
+ if (prev.top.get(key) === s) continue
213
+ ;(set ??= {})[key] = next.topValues[key]
214
+ if (!next.volatile.has(key)) meaningful = true
215
+ }
216
+ for (const key of prev.top.keys()) {
217
+ if (next.top.has(key)) continue
218
+ ;(unset ??= []).push(key)
219
+ if (!next.volatile.has(key)) meaningful = true
220
+ }
221
+ for (const [path, idField] of Object.entries(spec.collections)) {
222
+ const change = diffCollection(prev.collections.get(path), next.collections.get(path), idField)
223
+ if (!change) continue
224
+ ;(delta.collections ??= {})[path] = change
225
+ meaningful = true
226
+ }
227
+ if (!meaningful) return null
228
+ if (set) delta.set = set
229
+ if (unset) delta.unset = unset
230
+ return delta
231
+ }
232
+
233
+ function foldCollection(held: unknown, change: KeyedCollectionDelta, idField: string): Row[] {
234
+ const byId = new Map<string, Row>()
235
+ const heldOrder: string[] = []
236
+ for (const row of Array.isArray(held) ? held : []) {
237
+ if (!isRecord(row) || typeof row[idField] !== 'string') continue
238
+ byId.set(row[idField] as string, row)
239
+ heldOrder.push(row[idField] as string)
240
+ }
241
+ for (const id of change.removed ?? []) byId.delete(id)
242
+ for (const row of change.upsert ?? []) {
243
+ const id = row[idField]
244
+ if (typeof id !== 'string') continue
245
+ const base = byId.get(id)
246
+ byId.set(id, base ? { ...base, ...row } : { ...row })
247
+ }
248
+ for (const [id, keys] of Object.entries(change.unsetFields ?? {})) {
249
+ const base = byId.get(id)
250
+ if (!base) continue
251
+ const trimmed = { ...base }
252
+ for (const key of keys) delete trimmed[key]
253
+ byId.set(id, trimmed)
254
+ }
255
+ const order = change.order ?? heldOrder
256
+ const out: Row[] = []
257
+ const placed = new Set<string>()
258
+ for (const id of order) {
259
+ const row = byId.get(id)
260
+ if (!row || placed.has(id)) continue
261
+ out.push(row)
262
+ placed.add(id)
263
+ }
264
+ for (const [id, row] of byId) if (!placed.has(id)) out.push(row)
265
+ return out
266
+ }
267
+
268
+ /** Rebuild the full document from a held one plus a delta. Never mutates `held`. */
269
+ export function foldKeyedDoc<T extends Record<string, unknown>>(held: T, delta: KeyedDocDelta, spec: KeyedDocSpec): T {
270
+ // Collections are re-inserted below from the held ones, so the plain fields
271
+ // are folded on the stripped view (a nested parent keeps its other fields).
272
+ const heldCollections = new Map<string, unknown>()
273
+ for (const path of Object.keys(spec.collections)) heldCollections.set(path, readPath(held, path))
274
+ const next: Record<string, unknown> = stripCollections(held, spec)
275
+ for (const key of delta.unset ?? []) delete next[key]
276
+ Object.assign(next, delta.set ?? {})
277
+ for (const [path, idField] of Object.entries(spec.collections)) {
278
+ const change = delta.collections?.[path]
279
+ const heldList = heldCollections.get(path)
280
+ if (change?.absent) {
281
+ const [head, tail] = splitPath(path)
282
+ if (!tail) delete next[head]
283
+ continue
284
+ }
285
+ if (!change && heldList === undefined) continue
286
+ const list = change ? foldCollection(heldList, change, idField) : heldList
287
+ const [head, tail] = splitPath(path)
288
+ if (!tail) {
289
+ next[head] = list
290
+ continue
291
+ }
292
+ const parent = isRecord(next[head]) ? { ...(next[head] as Record<string, unknown>) } : {}
293
+ parent[tail] = list
294
+ next[head] = parent
295
+ }
296
+ return next as T
297
+ }
298
+
299
+ /**
300
+ * The `mesh.status` document: nodes keyed by `nodeId`, queue tasks and missions
301
+ * by `id`. `refreshedAt` and `sourceOfTruth` (its aggregate-cache age /
302
+ * returnedAt) are per-call stamps and the counters are live process tallies —
303
+ * none of them is a change by itself.
304
+ */
305
+ export const MESH_STATUS_DOC_SPEC: KeyedDocSpec = {
306
+ collections: { nodes: 'nodeId', 'queue.tasks': 'id', missions: 'id' },
307
+ volatileTopLevel: [
308
+ 'refreshedAt', 'sourceOfTruth',
309
+ 'turnPresentationCounters', 'meshProtocolV2Counters', 'pendingRetentionCounters', 'meshProtocolMetrics',
310
+ ],
311
+ stableRow: (collection, row) => (collection === 'nodes' ? stableMeshNodeRow(row) : row),
312
+ }
313
+
314
+ function without(value: unknown, keys: readonly string[]): unknown {
315
+ if (!isRecord(value)) return value
316
+ const out = { ...value }
317
+ for (const key of keys) delete out[key]
318
+ return out
319
+ }
320
+
321
+ /**
322
+ * A mesh node minus the stamps an aggregate rebuild re-derives from the clock
323
+ * (last-seen / updated / freshness age, the local read's check time, a local
324
+ * facts bundle's reportedAt). A REMOTE node's `gitObservation.observedAt` is
325
+ * real news (the member's own check time — the dashboard's "aged" hint) and
326
+ * is compared; the coordinator's own / local checkout's is its read time.
327
+ */
328
+ function stableMeshNodeRow(row: Record<string, unknown>): Record<string, unknown> {
329
+ const out: Record<string, unknown> = { ...row }
330
+ delete out.lastSeenAt
331
+ delete out.updatedAt
332
+ delete out.last_seen_at
333
+ delete out.updated_at
334
+ if ('dataFreshness' in out) out.dataFreshness = without(out.dataFreshness, ['ageMs', 'lastProbeAt', 'staleness'])
335
+ if ('connection' in out) out.connection = without(out.connection, ['lastStateChangeAt'])
336
+ if ('git' in out) {
337
+ const git = without(out.git, ['lastCheckedAt'])
338
+ // "No submodules" is the same fact whether a read listed none or skipped the scan.
339
+ if (isRecord(git) && Array.isArray(git.submodules) && git.submodules.length === 0) delete git.submodules
340
+ out.git = git
341
+ }
342
+ if ('nodeFacts' in out) out.nodeFacts = without(out.nodeFacts, ['reportedAt'])
343
+ const observation = out.gitObservation
344
+ if (isRecord(observation) && (observation.source === 'self' || observation.source === 'local')) {
345
+ out.gitObservation = without(observation, ['observedAt'])
346
+ }
347
+ return out
348
+ }