@adhdev/mesh-shared 1.0.60-rc.9 → 1.0.61-rc.1
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/dist/brain-routing.d.ts +6 -8
- package/dist/git-diff.d.ts +21 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +2876 -77
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2625 -72
- package/dist/index.mjs.map +1 -1
- package/dist/interpolation.d.ts +0 -9
- package/dist/json.d.ts +4 -0
- package/dist/keyed-doc-delta.d.ts +105 -0
- package/dist/mesh-tool-names.d.ts +31 -2
- package/dist/mesh-vocabulary.d.ts +66 -0
- package/dist/mission-brief.d.ts +62 -0
- package/dist/outbound-message.d.ts +201 -0
- package/dist/path-ownership.d.ts +101 -0
- package/dist/protocol/daemon-server.d.ts +356 -0
- package/dist/protocol/dashboard-daemon-p2p.d.ts +213 -0
- package/dist/protocol/dashboard-server.d.ts +240 -0
- package/dist/protocol/dashboard-wire-version.d.ts +21 -0
- package/dist/protocol/envelope.d.ts +55 -0
- package/dist/protocol/index.d.ts +16 -0
- package/dist/session-launch.d.ts +105 -0
- package/dist/session-status.d.ts +75 -0
- package/dist/slot-proposal.d.ts +2 -28
- package/dist/turn-evidence.d.ts +434 -0
- package/dist/turn-ipc-guards.d.ts +18 -0
- package/dist/turn-ipc-mission.d.ts +250 -0
- package/dist/turn-ipc-queue.d.ts +196 -0
- package/dist/turn-ipc-stats.d.ts +72 -0
- package/dist/turn-ipc-turn.d.ts +176 -0
- package/dist/turn-ipc.d.ts +90 -0
- package/dist/types.d.ts +11 -0
- package/dist/worker-protocol.d.ts +69 -0
- package/dist/ws-protocol.d.ts +69 -41
- package/package.json +1 -1
- package/src/brain-routing.ts +9 -11
- package/src/git-diff.ts +48 -0
- package/src/index.ts +12 -1
- package/src/interpolation.ts +0 -18
- package/src/json.ts +10 -0
- package/src/keyed-doc-delta.ts +413 -0
- package/src/mesh-tool-names.ts +66 -25
- package/src/mesh-vocabulary.ts +94 -0
- package/src/mission-brief.ts +153 -0
- package/src/outbound-message.ts +319 -0
- package/src/path-ownership.ts +271 -0
- package/src/protocol/daemon-server.ts +510 -0
- package/src/protocol/dashboard-daemon-p2p.ts +273 -0
- package/src/protocol/dashboard-server.ts +340 -0
- package/src/protocol/dashboard-wire-version.ts +27 -0
- package/src/protocol/envelope.ts +75 -0
- package/src/protocol/index.ts +17 -0
- package/src/session-launch.ts +245 -0
- package/src/session-status.ts +164 -0
- package/src/slot-proposal.ts +2 -48
- package/src/turn-evidence.ts +596 -0
- package/src/turn-ipc-guards.ts +61 -0
- package/src/turn-ipc-mission.ts +647 -0
- package/src/turn-ipc-queue.ts +493 -0
- package/src/turn-ipc-stats.ts +193 -0
- package/src/turn-ipc-turn.ts +453 -0
- package/src/turn-ipc.ts +137 -0
- package/src/types.ts +8 -0
- package/src/worker-protocol.ts +164 -0
- package/src/ws-protocol.ts +98 -93
- package/dist/magi.d.ts +0 -281
- package/src/magi.ts +0 -310
package/src/json.ts
CHANGED
|
@@ -14,6 +14,11 @@ export function readRecord(value: unknown): JsonRecord {
|
|
|
14
14
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** A plain-object record, or undefined — for callers that must tell "absent" from "empty". */
|
|
18
|
+
export function readOptionalRecord(value: unknown): JsonRecord | undefined {
|
|
19
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
export function readString(...values: unknown[]): string | undefined {
|
|
18
23
|
for (const value of values) {
|
|
19
24
|
if (typeof value !== 'string') continue
|
|
@@ -23,6 +28,11 @@ export function readString(...values: unknown[]): string | undefined {
|
|
|
23
28
|
return undefined
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
/** The first non-blank trimmed string, or '' — readString for callers that want a plain string. */
|
|
32
|
+
export function readText(...values: unknown[]): string {
|
|
33
|
+
return readString(...values) ?? ''
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
export function readNumber(...values: unknown[]): number | undefined {
|
|
27
37
|
for (const value of values) {
|
|
28
38
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed document delta — THE diff/fold engine behind every keyed dashboard
|
|
3
|
+
* lane: `daemon.metadata` (the daemon's state, sessions keyed by id) and
|
|
4
|
+
* `mesh.status` (the coordinator's mesh view, nodes / queue tasks / missions
|
|
5
|
+
* keyed) — data-path audit 2026-09-29 P1-4 (one engine, one spec per lane).
|
|
6
|
+
*
|
|
7
|
+
* A document is plain top-level fields, optional SPLIT objects (an object whose
|
|
8
|
+
* fields are diffed one by one, e.g. daemon.metadata's `status`), and named
|
|
9
|
+
* keyed COLLECTIONS (arrays of rows identified by an id field, possibly nested
|
|
10
|
+
* one object deep, e.g. `queue.tasks` / `status.sessions`). The daemon digests
|
|
11
|
+
* every body it would have sent, diffs it against the digest of the last body
|
|
12
|
+
* DELIVERED to a subscription, and sends only the difference: changed fields,
|
|
13
|
+
* and per collection the rows whose content changed (changed fields only), the
|
|
14
|
+
* fields a row lost, and the rows that disappeared. An unchanged document
|
|
15
|
+
* diffs to `null` — zero bytes on the wire. The dashboard folds each delta
|
|
16
|
+
* into its held snapshot; the fold of every delta since a snapshot equals the
|
|
17
|
+
* latest snapshot apart from `ignore`d fields (pinned by keyed-doc-delta.test.ts).
|
|
18
|
+
*
|
|
19
|
+
* Volatile fields (a build stamp, live process counters, a row's per-build
|
|
20
|
+
* stamps via `stableRow`) are not a change by themselves; when something else
|
|
21
|
+
* changed they ride along.
|
|
22
|
+
*
|
|
23
|
+
* Pure leaf: plain objects in, plain objects out; no transport, no clock.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface KeyedDocSpec {
|
|
27
|
+
/** Collection path (`nodes`, `queue.tasks`, `status.sessions`) → the id field of its rows. */
|
|
28
|
+
collections: Readonly<Record<string, string>>
|
|
29
|
+
/** Top-level objects whose own fields are diffed one by one (the rest of the object is not re-sent). */
|
|
30
|
+
splitObjects?: readonly string[]
|
|
31
|
+
/** Field paths (`daemonId`, `status.timestamp`) that are never diffed — envelope identity / clock. */
|
|
32
|
+
ignore?: readonly string[]
|
|
33
|
+
/** Field paths (top-level or inside a split object) that change on every build without meaning anything. */
|
|
34
|
+
volatileTopLevel?: readonly string[]
|
|
35
|
+
/**
|
|
36
|
+
* The part of a collection row that decides whether it CHANGED (drop the
|
|
37
|
+
* per-build stamps inside it). A row whose stable view is unchanged sends
|
|
38
|
+
* nothing; a changed row sends every field whose value differs, stamps
|
|
39
|
+
* included. Must not mutate `row`.
|
|
40
|
+
*/
|
|
41
|
+
stableRow?: (collection: string, row: Record<string, unknown>) => Record<string, unknown>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Row = Record<string, unknown>
|
|
45
|
+
|
|
46
|
+
interface RowDigest {
|
|
47
|
+
readonly value: Row
|
|
48
|
+
/** The row as compared (stableRow view). */
|
|
49
|
+
readonly stable: Row
|
|
50
|
+
readonly sig: string
|
|
51
|
+
fieldSigs?: Map<string, string>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CollectionDigest {
|
|
55
|
+
readonly rows: ReadonlyMap<string, RowDigest>
|
|
56
|
+
readonly order: readonly string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface KeyedDocDigest {
|
|
60
|
+
/** Field signatures by path (`field`, or `object.field` for a split object). */
|
|
61
|
+
readonly top: ReadonlyMap<string, string>
|
|
62
|
+
readonly topValues: Readonly<Record<string, unknown>>
|
|
63
|
+
readonly collections: ReadonlyMap<string, CollectionDigest>
|
|
64
|
+
readonly volatile: ReadonlySet<string>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface KeyedCollectionDelta {
|
|
68
|
+
/** New rows (whole) and changed rows (id + changed fields). */
|
|
69
|
+
upsert?: Row[]
|
|
70
|
+
/** Fields a row lost, by row id. */
|
|
71
|
+
unsetFields?: Record<string, string[]>
|
|
72
|
+
removed?: string[]
|
|
73
|
+
order?: string[]
|
|
74
|
+
/** The collection is no longer present in the document at all. */
|
|
75
|
+
absent?: true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface KeyedDocDelta {
|
|
79
|
+
set?: Record<string, unknown>
|
|
80
|
+
unset?: string[]
|
|
81
|
+
/** Per split object: its changed / lost fields. */
|
|
82
|
+
objects?: Record<string, { set?: Record<string, unknown>; unset?: string[] }>
|
|
83
|
+
collections?: Record<string, KeyedCollectionDelta>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sig(value: unknown): string | undefined {
|
|
87
|
+
if (value === undefined) return undefined
|
|
88
|
+
try {
|
|
89
|
+
return JSON.stringify(value)
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
96
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read the array at `path` (`a` or `a.b`) — undefined when absent. */
|
|
100
|
+
function readPath(doc: Record<string, unknown>, path: string): unknown {
|
|
101
|
+
const [head, tail] = splitPath(path)
|
|
102
|
+
if (!tail) return doc[head]
|
|
103
|
+
const parent = doc[head]
|
|
104
|
+
return isRecord(parent) ? parent[tail] : undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function splitPath(path: string): [string, string | undefined] {
|
|
108
|
+
const dot = path.indexOf('.')
|
|
109
|
+
return dot < 0 ? [path, undefined] : [path.slice(0, dot), path.slice(dot + 1)]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The document minus its collections (a nested collection is removed from a
|
|
114
|
+
* shallow copy of its parent, so the parent's other fields stay top-level data).
|
|
115
|
+
*/
|
|
116
|
+
function stripCollections(doc: Record<string, unknown>, spec: KeyedDocSpec): Record<string, unknown> {
|
|
117
|
+
const out: Record<string, unknown> = { ...doc }
|
|
118
|
+
for (const path of Object.keys(spec.collections)) {
|
|
119
|
+
const [head, tail] = splitPath(path)
|
|
120
|
+
if (!tail) {
|
|
121
|
+
delete out[head]
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
const parent = out[head]
|
|
125
|
+
if (!isRecord(parent) || !(tail in parent)) continue
|
|
126
|
+
const copy = { ...parent }
|
|
127
|
+
delete copy[tail]
|
|
128
|
+
out[head] = copy
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fieldSigsOf(digest: RowDigest): Map<string, string> {
|
|
134
|
+
if (!digest.fieldSigs) {
|
|
135
|
+
const sigs = new Map<string, string>()
|
|
136
|
+
for (const [key, value] of Object.entries(digest.stable)) {
|
|
137
|
+
const s = sig(value)
|
|
138
|
+
if (s !== undefined) sigs.set(key, s)
|
|
139
|
+
}
|
|
140
|
+
digest.fieldSigs = sigs
|
|
141
|
+
}
|
|
142
|
+
return digest.fieldSigs
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Digest one document. Cheap to hold: values are referenced, not copied. */
|
|
146
|
+
export function digestKeyedDoc(doc: Record<string, unknown>, spec: KeyedDocSpec): KeyedDocDigest {
|
|
147
|
+
const top = new Map<string, string>()
|
|
148
|
+
const topValues: Record<string, unknown> = {}
|
|
149
|
+
const ignore = new Set(spec.ignore ?? [])
|
|
150
|
+
const split = new Set(spec.splitObjects ?? [])
|
|
151
|
+
const add = (path: string, value: unknown) => {
|
|
152
|
+
if (ignore.has(path)) return
|
|
153
|
+
const s = sig(value)
|
|
154
|
+
if (s === undefined) return
|
|
155
|
+
top.set(path, s)
|
|
156
|
+
topValues[path] = value
|
|
157
|
+
}
|
|
158
|
+
for (const [key, value] of Object.entries(stripCollections(doc, spec))) {
|
|
159
|
+
if (split.has(key) && isRecord(value)) {
|
|
160
|
+
for (const [field, fieldValue] of Object.entries(value)) add(`${key}.${field}`, fieldValue)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
add(key, value)
|
|
164
|
+
}
|
|
165
|
+
const collections = new Map<string, CollectionDigest>()
|
|
166
|
+
for (const [path, idField] of Object.entries(spec.collections)) {
|
|
167
|
+
const list = readPath(doc, path)
|
|
168
|
+
if (!Array.isArray(list)) continue
|
|
169
|
+
const rows = new Map<string, RowDigest>()
|
|
170
|
+
const order: string[] = []
|
|
171
|
+
for (const row of list) {
|
|
172
|
+
if (!isRecord(row)) continue
|
|
173
|
+
const id = row[idField]
|
|
174
|
+
if (typeof id !== 'string' || !id || rows.has(id)) continue
|
|
175
|
+
const stable = spec.stableRow ? spec.stableRow(path, row) : row
|
|
176
|
+
rows.set(id, { value: row, stable, sig: sig(stable) ?? '' })
|
|
177
|
+
order.push(id)
|
|
178
|
+
}
|
|
179
|
+
collections.set(path, { rows, order })
|
|
180
|
+
}
|
|
181
|
+
return { top, topValues, collections, volatile: new Set(spec.volatileTopLevel ?? []) }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function sameOrder(a: readonly string[], b: readonly string[]): boolean {
|
|
185
|
+
if (a.length !== b.length) return false
|
|
186
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function diffCollection(prev: CollectionDigest | undefined, next: CollectionDigest | undefined, idField: string): KeyedCollectionDelta | null {
|
|
191
|
+
if (!next) return prev ? { absent: true } : null
|
|
192
|
+
const delta: KeyedCollectionDelta = {}
|
|
193
|
+
const prevRows = prev?.rows ?? new Map<string, RowDigest>()
|
|
194
|
+
const nextRows = next?.rows ?? new Map<string, RowDigest>()
|
|
195
|
+
for (const [id, nextRow] of nextRows) {
|
|
196
|
+
const prevRow = prevRows.get(id)
|
|
197
|
+
if (!prevRow) {
|
|
198
|
+
;(delta.upsert ??= []).push(nextRow.value)
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
if (prevRow.sig === nextRow.sig) continue
|
|
202
|
+
const prevSigs = fieldSigsOf(prevRow)
|
|
203
|
+
const nextSigs = fieldSigsOf(nextRow)
|
|
204
|
+
const change: Row = { [idField]: id }
|
|
205
|
+
let changed = false
|
|
206
|
+
for (const [key, s] of nextSigs) {
|
|
207
|
+
if (key === idField || prevSigs.get(key) === s) continue
|
|
208
|
+
change[key] = nextRow.value[key]
|
|
209
|
+
changed = true
|
|
210
|
+
}
|
|
211
|
+
// A real change carries the row's volatile fields (dropped by stableRow) along.
|
|
212
|
+
if (changed) {
|
|
213
|
+
for (const [key, value] of Object.entries(nextRow.value)) {
|
|
214
|
+
if (key in nextRow.stable || key in change) continue
|
|
215
|
+
if (sig(prevRow.value[key]) !== sig(value)) change[key] = value
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const gone: string[] = []
|
|
219
|
+
for (const key of prevSigs.keys()) if (!nextSigs.has(key) && !(key in nextRow.value)) gone.push(key)
|
|
220
|
+
if (changed) (delta.upsert ??= []).push(change)
|
|
221
|
+
if (gone.length > 0) (delta.unsetFields ??= {})[id] = gone
|
|
222
|
+
}
|
|
223
|
+
for (const id of prevRows.keys()) {
|
|
224
|
+
if (!nextRows.has(id)) (delta.removed ??= []).push(id)
|
|
225
|
+
}
|
|
226
|
+
if (!sameOrder(prev?.order ?? [], next?.order ?? [])) delta.order = [...(next?.order ?? [])]
|
|
227
|
+
return Object.keys(delta).length > 0 ? delta : null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The difference `prev → next`, or `null` when nothing a consumer can observe
|
|
232
|
+
* changed (volatile top-level fields alone are not a change).
|
|
233
|
+
*/
|
|
234
|
+
export function diffKeyedDoc(prev: KeyedDocDigest, next: KeyedDocDigest, spec: KeyedDocSpec): KeyedDocDelta | null {
|
|
235
|
+
const delta: KeyedDocDelta = {}
|
|
236
|
+
let meaningful = false
|
|
237
|
+
let set: Record<string, unknown> | undefined
|
|
238
|
+
let unset: string[] | undefined
|
|
239
|
+
const split = spec.splitObjects ?? []
|
|
240
|
+
const objectOf = (path: string): [string, string] | null => {
|
|
241
|
+
for (const object of split) {
|
|
242
|
+
if (path.startsWith(`${object}.`)) return [object, path.slice(object.length + 1)]
|
|
243
|
+
}
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
const objects: NonNullable<KeyedDocDelta['objects']> = {}
|
|
247
|
+
for (const [path, s] of next.top) {
|
|
248
|
+
if (prev.top.get(path) === s) continue
|
|
249
|
+
const inObject = objectOf(path)
|
|
250
|
+
if (inObject) ((objects[inObject[0]] ??= {}).set ??= {})[inObject[1]] = next.topValues[path]
|
|
251
|
+
else (set ??= {})[path] = next.topValues[path]
|
|
252
|
+
if (!next.volatile.has(path)) meaningful = true
|
|
253
|
+
}
|
|
254
|
+
for (const path of prev.top.keys()) {
|
|
255
|
+
if (next.top.has(path)) continue
|
|
256
|
+
const inObject = objectOf(path)
|
|
257
|
+
if (inObject) ((objects[inObject[0]] ??= {}).unset ??= []).push(inObject[1])
|
|
258
|
+
else (unset ??= []).push(path)
|
|
259
|
+
if (!next.volatile.has(path)) meaningful = true
|
|
260
|
+
}
|
|
261
|
+
for (const [path, idField] of Object.entries(spec.collections)) {
|
|
262
|
+
const change = diffCollection(prev.collections.get(path), next.collections.get(path), idField)
|
|
263
|
+
if (!change) continue
|
|
264
|
+
;(delta.collections ??= {})[path] = change
|
|
265
|
+
meaningful = true
|
|
266
|
+
}
|
|
267
|
+
if (!meaningful) return null
|
|
268
|
+
if (set) delta.set = set
|
|
269
|
+
if (unset) delta.unset = unset
|
|
270
|
+
if (Object.keys(objects).length > 0) delta.objects = objects
|
|
271
|
+
return delta
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function foldCollection(held: unknown, change: KeyedCollectionDelta, idField: string): Row[] {
|
|
275
|
+
const byId = new Map<string, Row>()
|
|
276
|
+
const heldOrder: string[] = []
|
|
277
|
+
for (const row of Array.isArray(held) ? held : []) {
|
|
278
|
+
if (!isRecord(row) || typeof row[idField] !== 'string') continue
|
|
279
|
+
byId.set(row[idField] as string, row)
|
|
280
|
+
heldOrder.push(row[idField] as string)
|
|
281
|
+
}
|
|
282
|
+
for (const id of change.removed ?? []) byId.delete(id)
|
|
283
|
+
for (const row of change.upsert ?? []) {
|
|
284
|
+
const id = row[idField]
|
|
285
|
+
if (typeof id !== 'string') continue
|
|
286
|
+
const base = byId.get(id)
|
|
287
|
+
byId.set(id, base ? { ...base, ...row } : { ...row })
|
|
288
|
+
}
|
|
289
|
+
for (const [id, keys] of Object.entries(change.unsetFields ?? {})) {
|
|
290
|
+
const base = byId.get(id)
|
|
291
|
+
if (!base) continue
|
|
292
|
+
const trimmed = { ...base }
|
|
293
|
+
for (const key of keys) delete trimmed[key]
|
|
294
|
+
byId.set(id, trimmed)
|
|
295
|
+
}
|
|
296
|
+
const order = change.order ?? heldOrder
|
|
297
|
+
const out: Row[] = []
|
|
298
|
+
const placed = new Set<string>()
|
|
299
|
+
for (const id of order) {
|
|
300
|
+
const row = byId.get(id)
|
|
301
|
+
if (!row || placed.has(id)) continue
|
|
302
|
+
out.push(row)
|
|
303
|
+
placed.add(id)
|
|
304
|
+
}
|
|
305
|
+
for (const [id, row] of byId) if (!placed.has(id)) out.push(row)
|
|
306
|
+
return out
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Rebuild the full document from a held one plus a delta. Never mutates `held`. */
|
|
310
|
+
export function foldKeyedDoc<T extends Record<string, unknown>>(held: T, delta: KeyedDocDelta, spec: KeyedDocSpec): T {
|
|
311
|
+
// Collections are re-inserted below from the held ones, so the plain fields
|
|
312
|
+
// are folded on the stripped view (a nested parent keeps its other fields).
|
|
313
|
+
const heldCollections = new Map<string, unknown>()
|
|
314
|
+
for (const path of Object.keys(spec.collections)) heldCollections.set(path, readPath(held, path))
|
|
315
|
+
const next: Record<string, unknown> = stripCollections(held, spec)
|
|
316
|
+
for (const key of delta.unset ?? []) delete next[key]
|
|
317
|
+
Object.assign(next, delta.set ?? {})
|
|
318
|
+
for (const [object, change] of Object.entries(delta.objects ?? {})) {
|
|
319
|
+
const folded: Record<string, unknown> = isRecord(next[object]) ? { ...(next[object] as Record<string, unknown>) } : {}
|
|
320
|
+
for (const key of change.unset ?? []) delete folded[key]
|
|
321
|
+
Object.assign(folded, change.set ?? {})
|
|
322
|
+
next[object] = folded
|
|
323
|
+
}
|
|
324
|
+
for (const [path, idField] of Object.entries(spec.collections)) {
|
|
325
|
+
const change = delta.collections?.[path]
|
|
326
|
+
const heldList = heldCollections.get(path)
|
|
327
|
+
if (change?.absent) {
|
|
328
|
+
const [head, tail] = splitPath(path)
|
|
329
|
+
if (!tail) delete next[head]
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
if (!change && heldList === undefined) continue
|
|
333
|
+
const list = change ? foldCollection(heldList, change, idField) : heldList
|
|
334
|
+
const [head, tail] = splitPath(path)
|
|
335
|
+
if (!tail) {
|
|
336
|
+
next[head] = list
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
const parent = isRecord(next[head]) ? { ...(next[head] as Record<string, unknown>) } : {}
|
|
340
|
+
parent[tail] = list
|
|
341
|
+
next[head] = parent
|
|
342
|
+
}
|
|
343
|
+
return next as T
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The `daemon.metadata` document (the snapshot body minus its envelope):
|
|
348
|
+
* `status` is split field by field, its sessions keyed by `id`. `daemonId` is
|
|
349
|
+
* envelope identity and `status.timestamp` the build clock — never diffed (the
|
|
350
|
+
* fold re-stamps them from the frame); a session's `lastUpdated` build stamp is
|
|
351
|
+
* not a change by itself.
|
|
352
|
+
*/
|
|
353
|
+
export const DAEMON_METADATA_DOC_SPEC: KeyedDocSpec = {
|
|
354
|
+
collections: { 'status.sessions': 'id' },
|
|
355
|
+
splitObjects: ['status'],
|
|
356
|
+
ignore: ['daemonId', 'status.timestamp'],
|
|
357
|
+
stableRow: (_collection, row) => {
|
|
358
|
+
if (!('lastUpdated' in row)) return row
|
|
359
|
+
const { lastUpdated: _stamp, ...stable } = row
|
|
360
|
+
return stable
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The `mesh.status` document: nodes keyed by `nodeId`, queue tasks and missions
|
|
366
|
+
* by `id`. `refreshedAt` and `sourceOfTruth` (its aggregate-cache age /
|
|
367
|
+
* returnedAt) are per-call stamps and the counters are live process tallies —
|
|
368
|
+
* none of them is a change by itself.
|
|
369
|
+
*/
|
|
370
|
+
export const MESH_STATUS_DOC_SPEC: KeyedDocSpec = {
|
|
371
|
+
collections: { nodes: 'nodeId', 'queue.tasks': 'id', missions: 'id' },
|
|
372
|
+
volatileTopLevel: [
|
|
373
|
+
'refreshedAt', 'sourceOfTruth',
|
|
374
|
+
'turnPresentationCounters', 'meshProtocolV2Counters', 'pendingRetentionCounters', 'meshProtocolMetrics',
|
|
375
|
+
],
|
|
376
|
+
stableRow: (collection, row) => (collection === 'nodes' ? stableMeshNodeRow(row) : row),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function without(value: unknown, keys: readonly string[]): unknown {
|
|
380
|
+
if (!isRecord(value)) return value
|
|
381
|
+
const out = { ...value }
|
|
382
|
+
for (const key of keys) delete out[key]
|
|
383
|
+
return out
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A mesh node minus the stamps an aggregate rebuild re-derives from the clock
|
|
388
|
+
* (last-seen / updated / freshness age, the local read's check time, a local
|
|
389
|
+
* facts bundle's reportedAt). A REMOTE node's `gitObservation.observedAt` is
|
|
390
|
+
* real news (the member's own check time — the dashboard's "aged" hint) and
|
|
391
|
+
* is compared; the coordinator's own / local checkout's is its read time.
|
|
392
|
+
*/
|
|
393
|
+
function stableMeshNodeRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
394
|
+
const out: Record<string, unknown> = { ...row }
|
|
395
|
+
delete out.lastSeenAt
|
|
396
|
+
delete out.updatedAt
|
|
397
|
+
delete out.last_seen_at
|
|
398
|
+
delete out.updated_at
|
|
399
|
+
if ('dataFreshness' in out) out.dataFreshness = without(out.dataFreshness, ['ageMs', 'lastProbeAt', 'staleness'])
|
|
400
|
+
if ('connection' in out) out.connection = without(out.connection, ['lastStateChangeAt'])
|
|
401
|
+
if ('git' in out) {
|
|
402
|
+
const git = without(out.git, ['lastCheckedAt'])
|
|
403
|
+
// "No submodules" is the same fact whether a read listed none or skipped the scan.
|
|
404
|
+
if (isRecord(git) && Array.isArray(git.submodules) && git.submodules.length === 0) delete git.submodules
|
|
405
|
+
out.git = git
|
|
406
|
+
}
|
|
407
|
+
if ('nodeFacts' in out) out.nodeFacts = without(out.nodeFacts, ['reportedAt'])
|
|
408
|
+
const observation = out.gitObservation
|
|
409
|
+
if (isRecord(observation) && (observation.source === 'self' || observation.source === 'local')) {
|
|
410
|
+
out.gitObservation = without(observation, ['observedAt'])
|
|
411
|
+
}
|
|
412
|
+
return out
|
|
413
|
+
}
|
package/src/mesh-tool-names.ts
CHANGED
|
@@ -22,16 +22,10 @@ export const CANONICAL_MESH_TOOL_NAMES = [
|
|
|
22
22
|
'mesh_status',
|
|
23
23
|
'mesh_route_preview',
|
|
24
24
|
'mesh_list_nodes',
|
|
25
|
-
//
|
|
25
|
+
// Batch before task, mirroring ALL_MESH_TOOLS.
|
|
26
26
|
'mesh_enqueue_batch',
|
|
27
27
|
'mesh_enqueue_task',
|
|
28
28
|
'mesh_view_queue',
|
|
29
|
-
// GRAPH-ORCHESTRATION Phase E — the coordinator gate + graph view surface.
|
|
30
|
-
'mesh_graph_view',
|
|
31
|
-
'mesh_graph_gate_claim',
|
|
32
|
-
'mesh_graph_gate_release',
|
|
33
|
-
'mesh_graph_gate_abandon',
|
|
34
|
-
'mesh_graph_node_patch',
|
|
35
29
|
'mesh_queue_cancel',
|
|
36
30
|
'mesh_queue_requeue',
|
|
37
31
|
'mesh_send_task',
|
|
@@ -48,7 +42,6 @@ export const CANONICAL_MESH_TOOL_NAMES = [
|
|
|
48
42
|
'mesh_approve',
|
|
49
43
|
'mesh_answer_question',
|
|
50
44
|
'mesh_list_pending_approvals',
|
|
51
|
-
'mesh_plan_onboarding',
|
|
52
45
|
'mesh_create',
|
|
53
46
|
'mesh_add_node',
|
|
54
47
|
'mesh_clone_node',
|
|
@@ -56,35 +49,83 @@ export const CANONICAL_MESH_TOOL_NAMES = [
|
|
|
56
49
|
'mesh_cleanup_worktree_nodes',
|
|
57
50
|
'mesh_refine_node',
|
|
58
51
|
'mesh_refine_batch',
|
|
59
|
-
'
|
|
60
|
-
'mesh_change_impact_config',
|
|
52
|
+
'mesh_config',
|
|
61
53
|
'mesh_init',
|
|
62
|
-
'mesh_reinit',
|
|
63
|
-
'mesh_write_mesh_json_config',
|
|
64
54
|
'mesh_refine_plan',
|
|
65
55
|
'mesh_cleanup_sessions',
|
|
66
|
-
'mesh_prune_stale_direct',
|
|
67
56
|
'mesh_task_history',
|
|
68
57
|
'mesh_ledger_query',
|
|
69
|
-
'
|
|
70
|
-
'mesh_forget_note',
|
|
58
|
+
'mesh_note',
|
|
71
59
|
'mesh_reconcile_ledger',
|
|
72
|
-
'mesh_requeue_held_events',
|
|
73
60
|
'mesh_mission_upsert',
|
|
74
61
|
'mesh_mission_list',
|
|
75
62
|
'mesh_review_inbox',
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
'mesh_magi_kind_panel_set',
|
|
79
|
-
'mesh_magi_kind_panel_list',
|
|
80
|
-
'mesh_node_slots_set',
|
|
81
|
-
'mesh_node_slots_list',
|
|
82
|
-
'mesh_node_slots_propose',
|
|
83
|
-
'mesh_coordinator_prompt_append_get',
|
|
84
|
-
'mesh_coordinator_prompt_append_set',
|
|
63
|
+
'mesh_node_slots',
|
|
64
|
+
'mesh_coordinator_prompt_append',
|
|
85
65
|
] as const;
|
|
86
66
|
|
|
87
67
|
export type CanonicalMeshToolName = typeof CANONICAL_MESH_TOOL_NAMES[number];
|
|
88
68
|
|
|
89
69
|
/** The count the `NN tools` barrel doc comments and consistency test assert against. */
|
|
90
70
|
export const CANONICAL_MESH_TOOL_COUNT = CANONICAL_MESH_TOOL_NAMES.length;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Tool names retired by the 2026-09-26 tool-surface consolidation (60 → 48).
|
|
74
|
+
* Every capability behind them is still reachable: each retired name maps to the
|
|
75
|
+
* merged tool plus the discriminator argument that selects the same behaviour.
|
|
76
|
+
*
|
|
77
|
+
* mcp-server answers a call to a retired name with an error naming the
|
|
78
|
+
* replacement (it does NOT forward silently — a coordinator running an old
|
|
79
|
+
* prompt must learn the new spelling), and the daemon-core coordinator prompt
|
|
80
|
+
* test asserts none of these names is advertised anywhere in the prompt.
|
|
81
|
+
*
|
|
82
|
+
* `args` is the discriminator to add; every other argument keeps its name.
|
|
83
|
+
* `note` carries any extra guidance when an argument moved in a rename.
|
|
84
|
+
*/
|
|
85
|
+
export const RETIRED_MESH_TOOLS: Readonly<Record<string, {
|
|
86
|
+
readonly tool: CanonicalMeshToolName;
|
|
87
|
+
readonly args: Readonly<Record<string, string>>;
|
|
88
|
+
readonly note?: string;
|
|
89
|
+
}>> = {
|
|
90
|
+
mesh_node_slots_set: { tool: 'mesh_node_slots', args: { action: 'set' } },
|
|
91
|
+
mesh_node_slots_list: { tool: 'mesh_node_slots', args: { action: 'list' } },
|
|
92
|
+
mesh_node_slots_propose: { tool: 'mesh_node_slots', args: { action: 'propose' } },
|
|
93
|
+
mesh_coordinator_prompt_append_get: { tool: 'mesh_coordinator_prompt_append', args: { action: 'get' } },
|
|
94
|
+
mesh_coordinator_prompt_append_set: { tool: 'mesh_coordinator_prompt_append', args: { action: 'set' } },
|
|
95
|
+
mesh_record_note: { tool: 'mesh_note', args: { action: 'record' } },
|
|
96
|
+
mesh_forget_note: { tool: 'mesh_note', args: { action: 'forget' } },
|
|
97
|
+
mesh_reinit: { tool: 'mesh_init', args: { mode: 'reinit' } },
|
|
98
|
+
mesh_refine_config: { tool: 'mesh_config', args: { kind: 'refine' } },
|
|
99
|
+
mesh_change_impact_config: { tool: 'mesh_config', args: { kind: 'change_impact' } },
|
|
100
|
+
mesh_write_mesh_json_config: { tool: 'mesh_config', args: { kind: 'mesh_json' } },
|
|
101
|
+
mesh_plan_onboarding: { tool: 'mesh_create', args: { mode: 'plan' } },
|
|
102
|
+
mesh_prune_stale_direct: { tool: 'mesh_cleanup_sessions', args: { mode: 'prune_stale_direct' } },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Tool names removed outright (no merged replacement), mapped to the guidance a
|
|
107
|
+
* coordinator on an old prompt needs to do the same job with the remaining tools.
|
|
108
|
+
*/
|
|
109
|
+
export const REMOVED_MESH_TOOLS: Readonly<Record<string, string>> = {
|
|
110
|
+
mesh_magi_review: 'For a multi-perspective review, send the same question to 2-3 workers on different providers with mesh_send_task, wait for their report_completion, then synthesize the answers yourself.',
|
|
111
|
+
mesh_magi_collect: 'Worker answers arrive as report_completion events; synthesize them yourself.',
|
|
112
|
+
mesh_magi_kind_panel: 'There are no review panels any more; pick the workers per review with mesh_send_task.',
|
|
113
|
+
mesh_magi_kind_panel_set: 'There are no review panels any more; pick the workers per review with mesh_send_task.',
|
|
114
|
+
mesh_magi_kind_panel_list: 'There are no review panels any more; pick the workers per review with mesh_send_task.',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The error text for a call to a retired or removed tool name, or null when
|
|
119
|
+
* `name` is neither. One wording shared by every MCP mode so the redirect reads
|
|
120
|
+
* the same wherever an old prompt calls it.
|
|
121
|
+
*/
|
|
122
|
+
export function retiredMeshToolError(name: string): string | null {
|
|
123
|
+
if (Object.prototype.hasOwnProperty.call(REMOVED_MESH_TOOLS, name)) {
|
|
124
|
+
return `Tool "${name}" was removed. ${REMOVED_MESH_TOOLS[name]}`;
|
|
125
|
+
}
|
|
126
|
+
const entry = Object.prototype.hasOwnProperty.call(RETIRED_MESH_TOOLS, name) ? RETIRED_MESH_TOOLS[name] : undefined;
|
|
127
|
+
if (!entry) return null;
|
|
128
|
+
const discriminator = Object.entries(entry.args).map(([k, v]) => `${k}: "${v}"`).join(', ');
|
|
129
|
+
return `Tool "${name}" was retired (merged into ${entry.tool}). Call ${entry.tool} with ${discriminator} and the same other arguments.`
|
|
130
|
+
+ (entry.note ? ` ${entry.note}` : '');
|
|
131
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh-vocabulary — the ONE declaration of every mesh enum.
|
|
3
|
+
*
|
|
4
|
+
* Wiring-unification Phase A3 (docs/design/2026-09-23-wiring-unification.md).
|
|
5
|
+
*
|
|
6
|
+
* Before this file `MeshTaskStatus` was declared twice in daemon-core with
|
|
7
|
+
* different members (one carried `in_progress`, which no task row ever held),
|
|
8
|
+
* a third time in `repo-mesh-types.ts`, and loosened to `| string` in web-core;
|
|
9
|
+
* the MCP tool schemas hand-copied the task-mode list six times. Every enum a
|
|
10
|
+
* mesh tool, ledger row, queue entry or dashboard view branches on is declared
|
|
11
|
+
* here once, as a `const` tuple, and the type is derived from it. JSON-schema
|
|
12
|
+
* producers use `enumOf()` so the published schema cannot drift from the code.
|
|
13
|
+
*
|
|
14
|
+
* `MeshTaskDifficulty` / `MESH_TASK_DIFFICULTIES` already live in
|
|
15
|
+
* ./brain-routing.ts and are re-exported here for one-stop discovery.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export { MESH_TASK_DIFFICULTIES, type MeshTaskDifficulty } from './brain-routing'
|
|
19
|
+
|
|
20
|
+
export const MESH_TASK_STATUSES = ['pending', 'assigned', 'completed', 'failed', 'cancelled'] as const
|
|
21
|
+
export type MeshTaskStatus = typeof MESH_TASK_STATUSES[number]
|
|
22
|
+
|
|
23
|
+
export const MESH_TERMINAL_TASK_STATUSES = ['completed', 'failed', 'cancelled'] as const
|
|
24
|
+
export type MeshTerminalTaskStatus = typeof MESH_TERMINAL_TASK_STATUSES[number]
|
|
25
|
+
|
|
26
|
+
export const MESH_TASK_MODES = ['code_change', 'validation', 'live_debug_readonly', 'launch_app', 'convergence'] as const
|
|
27
|
+
export type MeshTaskMode = typeof MESH_TASK_MODES[number]
|
|
28
|
+
|
|
29
|
+
export const MESH_TASK_PRIORITIES = ['low', 'normal', 'high'] as const
|
|
30
|
+
export type MeshTaskPriority = typeof MESH_TASK_PRIORITIES[number]
|
|
31
|
+
|
|
32
|
+
export const MESH_THINKING_LEVELS = ['low', 'medium', 'high'] as const
|
|
33
|
+
export type MeshThinkingLevel = typeof MESH_THINKING_LEVELS[number]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How a message is delivered to a session that may be busy.
|
|
37
|
+
*
|
|
38
|
+
* 'when_idle' — DEFAULT. Never disturbs a running turn; a busy session's task
|
|
39
|
+
* is queued and auto-delivered on its next idle transition.
|
|
40
|
+
* 'interrupt' — Abort the in-flight turn (the provider's own stop control),
|
|
41
|
+
* then deliver once the session settles to idle. The name is
|
|
42
|
+
* deliberately blunt: the unfinished work is LOST. `immediate`
|
|
43
|
+
* was rejected as a name because it reads like a gentle overlay.
|
|
44
|
+
*/
|
|
45
|
+
export const MESH_DELIVERY_MODES = ['when_idle', 'interrupt'] as const
|
|
46
|
+
export type MeshDeliveryMode = typeof MESH_DELIVERY_MODES[number]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What to do with a node's sessions when the node is removed. Declared here so daemon-core (`RepoMeshSessionCleanupMode`), the
|
|
50
|
+
* web dashboard picker and the MCP schemas all read one list.
|
|
51
|
+
*/
|
|
52
|
+
export const MESH_SESSION_CLEANUP_MODES = ['preserve', 'stop', 'delete_stopped', 'stop_and_delete'] as const
|
|
53
|
+
export type MeshSessionCleanupMode = typeof MESH_SESSION_CLEANUP_MODES[number]
|
|
54
|
+
|
|
55
|
+
/** Outcome a delegated worker may report through `report_completion`. */
|
|
56
|
+
export const WORKER_REPORT_OUTCOMES = ['completed', 'blocked', 'failed'] as const
|
|
57
|
+
export type WorkerReportOutcome = typeof WORKER_REPORT_OUTCOMES[number]
|
|
58
|
+
|
|
59
|
+
/** Final branch state a worker reports (see coordinator convergence rules). */
|
|
60
|
+
export const WORKER_BRANCH_STATES = [
|
|
61
|
+
'merged_to_main',
|
|
62
|
+
'pushed_feature_branch_needs_merge',
|
|
63
|
+
'blocked_review',
|
|
64
|
+
'cleanup_candidate',
|
|
65
|
+
'not_mergeable',
|
|
66
|
+
] as const
|
|
67
|
+
export type WorkerBranchState = typeof WORKER_BRANCH_STATES[number]
|
|
68
|
+
|
|
69
|
+
function makeGuard<T extends readonly string[]>(values: T): (value: unknown) => value is T[number] {
|
|
70
|
+
const set: ReadonlySet<string> = new Set(values)
|
|
71
|
+
return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const isMeshTaskStatus = makeGuard(MESH_TASK_STATUSES)
|
|
75
|
+
export const isMeshTerminalTaskStatus = makeGuard(MESH_TERMINAL_TASK_STATUSES)
|
|
76
|
+
export const isMeshTaskMode = makeGuard(MESH_TASK_MODES)
|
|
77
|
+
export const isMeshTaskPriority = makeGuard(MESH_TASK_PRIORITIES)
|
|
78
|
+
export const isMeshDeliveryMode = makeGuard(MESH_DELIVERY_MODES)
|
|
79
|
+
export const isWorkerReportOutcome = makeGuard(WORKER_REPORT_OUTCOMES)
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* JSON-schema fragment for a string enum, derived from the tuple. Use this in
|
|
83
|
+
* every MCP tool schema instead of spelling the member list again.
|
|
84
|
+
*/
|
|
85
|
+
export function enumOf<T extends readonly string[]>(
|
|
86
|
+
values: T,
|
|
87
|
+
description?: string,
|
|
88
|
+
): { type: 'string'; enum: T[number][]; description?: string } {
|
|
89
|
+
return {
|
|
90
|
+
type: 'string',
|
|
91
|
+
enum: [...values] as T[number][],
|
|
92
|
+
...(description ? { description } : {}),
|
|
93
|
+
}
|
|
94
|
+
}
|