@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/README_zh.md +3 -0
- package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
- package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +445 -57
- package/lib/a2a-client.js +9 -4
- package/lib/auth-observer.js +254 -0
- package/lib/blob-store.js +210 -0
- package/lib/card.js +4 -2
- package/lib/cli-deposit.js +122 -7
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +133 -43
- package/lib/faces/canvas-notify.js +12 -3
- package/lib/faces/files.js +155 -0
- package/lib/faces/nodes.js +171 -0
- package/lib/faces/session-log.js +162 -0
- package/lib/faces/wire.js +206 -39
- package/lib/index.js +809 -1720
- package/lib/joined-store.js +23 -0
- package/lib/ledger-conservation.js +97 -0
- package/lib/legacy/teams-bridge-inbound.js +156 -0
- package/lib/node-bind.js +534 -0
- package/lib/node-github.js +72 -0
- package/lib/node-self.js +453 -0
- package/lib/node-store.js +484 -0
- package/lib/node-tasks.js +342 -0
- package/lib/origin-claim.js +246 -0
- package/lib/peer-directory.js +134 -0
- package/lib/receipt.js +26 -1
- package/lib/skill/SKILL.md +3 -1
- package/lib/skill/references/cli.md +82 -3
- package/lib/skill/references/collaboration.md +146 -73
- package/lib/skill/references/discovery.md +29 -0
- package/lib/skill/references/intake.md +107 -0
- package/lib/skill/references/router.md +96 -0
- package/lib/skill/references/setup.md +2 -1
- package/lib/task-auth.js +483 -0
- package/lib/task-ledger.js +324 -12
- package/lib/team-store.js +24 -1
- package/lib/tools/act.js +383 -0
- package/lib/tools/files.js +201 -0
- package/lib/tools/info.js +684 -0
- package/lib/types/a2a-client.d.ts +10 -4
- package/lib/types/auth-observer.d.ts +110 -0
- package/lib/types/blob-store.d.ts +83 -0
- package/lib/types/cli-deposit.d.ts +38 -0
- package/lib/types/faces/admin.d.ts +14 -2
- package/lib/types/faces/canvas-notify.d.ts +4 -1
- package/lib/types/faces/files.d.ts +53 -0
- package/lib/types/faces/nodes.d.ts +69 -0
- package/lib/types/faces/session-log.d.ts +44 -0
- package/lib/types/faces/wire.d.ts +50 -5
- package/lib/types/index.d.ts +27 -0
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/ledger-conservation.d.ts +77 -0
- package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
- package/lib/types/node-bind.d.ts +220 -0
- package/lib/types/node-github.d.ts +35 -0
- package/lib/types/node-self.d.ts +178 -0
- package/lib/types/node-store.d.ts +180 -0
- package/lib/types/node-tasks.d.ts +121 -0
- package/lib/types/origin-claim.d.ts +119 -0
- package/lib/types/peer-directory.d.ts +39 -0
- package/lib/types/receipt.d.ts +12 -0
- package/lib/types/task-auth.d.ts +271 -0
- package/lib/types/task-ledger.d.ts +143 -6
- package/lib/types/team-store.d.ts +9 -0
- package/lib/types/tools/act.d.ts +52 -0
- package/lib/types/tools/files.d.ts +38 -0
- package/lib/types/tools/info.d.ts +102 -0
- package/lib/types/types.d.ts +6 -0
- package/nexus-stage/src/canvas-ops.ts +17 -2
- package/nexus-stage/src/canvas-wire.ts +27 -19
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +41 -18
- package/nexus-stage/src/planning-member-handles.ts +34 -0
- package/nexus-stage/src/planning-mirror.ts +47 -3
- package/nexus-stage/src/planning-reachability.ts +28 -0
- package/nexus-stage/src/planning-types.ts +1 -1
- package/nexus-stage/src/planning-view.ts +30 -27
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-directory.ts +13 -0
- package/nexus-stage/src/workbench.css +3 -0
- package/nexus-stage/src/workbench.ts +5 -1
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
- package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
package/lib/task-ledger.js
CHANGED
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
* illusion: settlements vanished while debts piled up).
|
|
19
19
|
* @module @nelsonlongxiang/dsh-open-a2a-net/task-ledger
|
|
20
20
|
*/
|
|
21
|
+
import { parseAuthorization } from "./task-auth.js";
|
|
21
22
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
23
|
import { dirname } from 'node:path';
|
|
23
24
|
import { parseReceipt, RECEIPT_OUTCOMES } from "./receipt.js";
|
|
25
|
+
import { classifyConservationBlock, ConservationCounters } from "./ledger-conservation.js";
|
|
24
26
|
/**
|
|
25
27
|
* Caller-ended summaries (abandon / cancel) share one isolation law: late
|
|
26
28
|
* receipts never rewrite the verdict — they only stamp lateReceiptAt, and
|
|
@@ -63,6 +65,96 @@ export const TASK_STALE_TTL_MS = 24 * 60 * 60_000;
|
|
|
63
65
|
const ARCHIVE_ENDS = ['receipt', 'echo', 'abandoned', 'cancelled', 'evicted'];
|
|
64
66
|
/** Summary marker {@link TaskLedger.resolveEcho} writes on echo-settled rows. */
|
|
65
67
|
const ECHO_SUMMARY = '(settled by content echo)';
|
|
68
|
+
const SENDER_KINDS = ['session', 'host', 'bridge'];
|
|
69
|
+
/** Longest sender id kept; anything longer is malformed, not truncated. */
|
|
70
|
+
export const SENDER_ID_CAP = 256;
|
|
71
|
+
/**
|
|
72
|
+
* Restore-side allowlist for one persisted sender: a well-formed value comes
|
|
73
|
+
* back as a fresh object holding only kind, id and (when present) face; anything else is dropped whole (the row
|
|
74
|
+
* itself survives — a bad sender never costs the debt record).
|
|
75
|
+
* @param raw - the persisted value.
|
|
76
|
+
* @returns the sender, or undefined when absent or malformed.
|
|
77
|
+
*/
|
|
78
|
+
export function parseSender(raw) {
|
|
79
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
80
|
+
return undefined;
|
|
81
|
+
const { kind, id, face } = raw;
|
|
82
|
+
if (typeof kind !== 'string' || !SENDER_KINDS.includes(kind))
|
|
83
|
+
return undefined;
|
|
84
|
+
if (typeof id !== 'string' || id === '' || id.length > SENDER_ID_CAP)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (face !== undefined && (typeof face !== 'string' || face === '' || face.length > SENDER_ID_CAP))
|
|
87
|
+
return undefined;
|
|
88
|
+
// `face` belongs to host senders only; on any other kind it is dropped, the sender kept.
|
|
89
|
+
return { kind: kind, id, ...(face !== undefined && kind === 'host' ? { face } : {}) };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Spread helper for restore: `{ sender }` when the persisted value parses and
|
|
93
|
+
* the row is not inbound (an inbound row's originator is its caller, never a
|
|
94
|
+
* sender this host recorded), else nothing.
|
|
95
|
+
*/
|
|
96
|
+
function withSender(raw, direction) {
|
|
97
|
+
if (direction === 'inbound')
|
|
98
|
+
return {};
|
|
99
|
+
const sender = parseSender(raw);
|
|
100
|
+
return sender === undefined ? {} : { sender };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* #114 S2: `{ auth }` when a persisted INBOUND row carries a record that
|
|
104
|
+
* parses whole (parseAuthorization) and belongs to THIS row — its target is
|
|
105
|
+
* the row's team and its receipt target the row's peer — else nothing: the
|
|
106
|
+
* row survives as a pre-S2 row, never with a partially adopted or borrowed
|
|
107
|
+
* record.
|
|
108
|
+
*/
|
|
109
|
+
function withAuth(raw, entry, now) {
|
|
110
|
+
if (entry.direction !== 'inbound' && entry.direction !== 'outbound')
|
|
111
|
+
return {};
|
|
112
|
+
const auth = parseAuthorization(raw, now);
|
|
113
|
+
if (auth === undefined || auth.target !== entry.team)
|
|
114
|
+
return {};
|
|
115
|
+
if (entry.direction === 'inbound')
|
|
116
|
+
return auth.ingress === 'wire' && `local:${auth.receipt_target}` === entry.peer ? { auth } : {};
|
|
117
|
+
// #139: an outbound row carries only its local-candidate admission.
|
|
118
|
+
return auth.ingress === 'local' && entry.peer === 'local' ? { auth } : {};
|
|
119
|
+
}
|
|
120
|
+
/** #139: the routing facts settlement reads, copied between a row and its archive entry. */
|
|
121
|
+
function routingOf(row) {
|
|
122
|
+
return {
|
|
123
|
+
...(row.peerKey !== undefined ? { peerKey: row.peerKey } : {}),
|
|
124
|
+
...(row.peerHost !== undefined ? { peerHost: row.peerHost } : {}),
|
|
125
|
+
...(row.deliveredTo !== undefined ? { deliveredTo: row.deliveredTo } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** The facts of one outbound birth that fit the row (see {@link withRouting} for the same shape on restore). */
|
|
129
|
+
function birthFacts(peer, birth) {
|
|
130
|
+
if (birth === undefined)
|
|
131
|
+
return {};
|
|
132
|
+
const fact = (v) => v !== undefined && v !== '' && v.length <= ROUTING_FACT_CAP;
|
|
133
|
+
const remote = peer !== 'local' && !peer.startsWith('local:');
|
|
134
|
+
return {
|
|
135
|
+
...(!remote && birth.auth !== undefined && birth.auth.ingress === 'local' ? { auth: birth.auth } : {}),
|
|
136
|
+
...(remote && fact(birth.peerKey) ? { peerKey: birth.peerKey } : {}),
|
|
137
|
+
...(remote && fact(birth.peerHost) ? { peerHost: birth.peerHost } : {}),
|
|
138
|
+
...(fact(birth.deliveredTo) ? { deliveredTo: birth.deliveredTo } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Bound on a persisted routing fact; anything longer (or empty, or not a string) is dropped on restore. */
|
|
142
|
+
const ROUTING_FACT_CAP = 256;
|
|
143
|
+
/**
|
|
144
|
+
* #139: restore-side allowlist for the routing facts. Each is kept only in
|
|
145
|
+
* its shape — a non-empty string within the cap — and the peer facts only on
|
|
146
|
+
* a remote outbound row; anything else is dropped (the row survives as one
|
|
147
|
+
* born before the fields, judged by current membership and origin).
|
|
148
|
+
*/
|
|
149
|
+
function withRouting(entry) {
|
|
150
|
+
const fact = (v) => typeof v === 'string' && v !== '' && v.length <= ROUTING_FACT_CAP;
|
|
151
|
+
const remoteOutbound = entry.direction === 'outbound' && typeof entry.peer === 'string' && entry.peer !== 'local' && !entry.peer.startsWith('local:');
|
|
152
|
+
return {
|
|
153
|
+
...(remoteOutbound && fact(entry.peerKey) ? { peerKey: entry.peerKey } : {}),
|
|
154
|
+
...(remoteOutbound && fact(entry.peerHost) ? { peerHost: entry.peerHost } : {}),
|
|
155
|
+
...(fact(entry.deliveredTo) ? { deliveredTo: entry.deliveredTo } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
66
158
|
/**
|
|
67
159
|
* How one archived row ended (gh#31). The recorded field wins; legacy rows
|
|
68
160
|
* fall back to the summary markers this ledger writes itself. The fallback
|
|
@@ -83,6 +175,39 @@ export function archivedEndedBy(row) {
|
|
|
83
175
|
return 'echo';
|
|
84
176
|
return 'receipt';
|
|
85
177
|
}
|
|
178
|
+
/** Bound on ids and callers kept in settlement records; longer values are cut on write and refused on restore. */
|
|
179
|
+
const SETTLEMENT_ID_CAP = 256;
|
|
180
|
+
/** Bound of the refusal ring (design §4.4). */
|
|
181
|
+
export const SETTLEMENT_REFUSAL_CAP = 64;
|
|
182
|
+
const SETTLEMENT_REASONS = ['settler-off-chain', 'settler-unverified', 'on-behalf-mismatch', 'echo-off-chain'];
|
|
183
|
+
const CHAIN_ROLES = ['target', 'target-member', 'host-face', 'compat', 'legacy', 'off-chain'];
|
|
184
|
+
const PROOF_VALUES = ['origin-claim', 'claim-mismatch', 'legacy-window', 'unbound', 'cli-identity', 'in-process', 'none'];
|
|
185
|
+
/** Restore-side allowlist for settledBy: whole or nothing. */
|
|
186
|
+
function withSettledBy(raw) {
|
|
187
|
+
if (raw === null || typeof raw !== 'object')
|
|
188
|
+
return {};
|
|
189
|
+
const r = raw;
|
|
190
|
+
if (typeof r.caller !== 'string' || r.caller.length > SETTLEMENT_ID_CAP || typeof r.chain_role !== 'string' || !CHAIN_ROLES.includes(r.chain_role))
|
|
191
|
+
return {};
|
|
192
|
+
return { settledBy: { caller: r.caller, chain_role: r.chain_role } };
|
|
193
|
+
}
|
|
194
|
+
/** Restore-side allowlist for one refusal record: whole or nothing. */
|
|
195
|
+
function parseRefusal(raw) {
|
|
196
|
+
if (raw === null || typeof raw !== 'object')
|
|
197
|
+
return undefined;
|
|
198
|
+
const r = raw;
|
|
199
|
+
const st = r.settler;
|
|
200
|
+
if (typeof r.taskId !== 'string' || r.taskId === '' || r.taskId.length > SETTLEMENT_ID_CAP || typeof r.at !== 'number' || !Number.isFinite(r.at))
|
|
201
|
+
return undefined;
|
|
202
|
+
if (typeof r.reason !== 'string' || !SETTLEMENT_REASONS.includes(r.reason) || typeof r.head !== 'string' || typeof r.enforced !== 'boolean')
|
|
203
|
+
return undefined;
|
|
204
|
+
if (st === null || typeof st !== 'object' || typeof st.caller !== 'string' || st.caller.length > SETTLEMENT_ID_CAP || typeof st.proof !== 'string' || !PROOF_VALUES.includes(st.proof) || (st.ingress !== 'wire' && st.ingress !== 'local'))
|
|
205
|
+
return undefined;
|
|
206
|
+
const count = r.count === undefined ? 1 : r.count;
|
|
207
|
+
if (typeof count !== 'number' || !Number.isInteger(count) || count < 1)
|
|
208
|
+
return undefined;
|
|
209
|
+
return { taskId: r.taskId, at: r.at, settler: { caller: st.caller, proof: st.proof, ingress: st.ingress }, reason: r.reason, head: r.head.slice(0, SUMMARY_CAP), enforced: r.enforced, count };
|
|
210
|
+
}
|
|
86
211
|
/**
|
|
87
212
|
* Bounded outbound-task ledger with whole-file persistence. Pure of any
|
|
88
213
|
* routing knowledge: callers {@link track} a task when a route leaves it
|
|
@@ -95,6 +220,12 @@ export class TaskLedger {
|
|
|
95
220
|
tasks = [];
|
|
96
221
|
/** Settled rows, most recently resolved first; bounded by the archive cap. */
|
|
97
222
|
archived = [];
|
|
223
|
+
/** #114 S4: refused / would-be-refused settlements, newest first. */
|
|
224
|
+
refusals = [];
|
|
225
|
+
/** #114 S4: every refusal ever noted (monotonic; survives the ring's bound). */
|
|
226
|
+
refusalsTotal = 0;
|
|
227
|
+
/** gh#154: lifetime conservation counters (persisted; never reset by cap trims or restarts) and the durable damage marker — the machinery lives in src/ledger-conservation.ts (R-mod); the ledger's seams delegate. */
|
|
228
|
+
counters = new ConservationCounters();
|
|
98
229
|
staleTtlMs;
|
|
99
230
|
archiveCap;
|
|
100
231
|
/** Injectable clock (option `now`). */
|
|
@@ -147,7 +278,7 @@ export class TaskLedger {
|
|
|
147
278
|
* via context_id follow-ups (the dispatch reply already says so).
|
|
148
279
|
* Idempotent by task id like {@link track}.
|
|
149
280
|
*/
|
|
150
|
-
trackReceiptless(taskId, team, peer, contextId, brief) {
|
|
281
|
+
trackReceiptless(taskId, team, peer, contextId, brief, sender, birth) {
|
|
151
282
|
if (taskId === '')
|
|
152
283
|
return;
|
|
153
284
|
this.sweep(this.now());
|
|
@@ -155,6 +286,7 @@ export class TaskLedger {
|
|
|
155
286
|
return;
|
|
156
287
|
if (this.archived.some(entry => entry.taskId === taskId))
|
|
157
288
|
return;
|
|
289
|
+
this.counters.trackedTotal += 1;
|
|
158
290
|
this.tasks = [{
|
|
159
291
|
taskId,
|
|
160
292
|
team,
|
|
@@ -164,12 +296,14 @@ export class TaskLedger {
|
|
|
164
296
|
receiptExpected: false,
|
|
165
297
|
...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
|
|
166
298
|
...(brief !== undefined && brief !== '' ? { brief } : {}),
|
|
299
|
+
...(sender !== undefined ? { sender } : {}),
|
|
300
|
+
...birthFacts(peer, birth),
|
|
167
301
|
status: 'pending',
|
|
168
302
|
}, ...this.tasks];
|
|
169
303
|
this.trimOwed();
|
|
170
304
|
this.persist();
|
|
171
305
|
}
|
|
172
|
-
track(taskId, team, peer, contextId, brief) {
|
|
306
|
+
track(taskId, team, peer, contextId, brief, sender, birth) {
|
|
173
307
|
if (taskId === '')
|
|
174
308
|
return;
|
|
175
309
|
this.sweep(this.now());
|
|
@@ -177,6 +311,7 @@ export class TaskLedger {
|
|
|
177
311
|
return;
|
|
178
312
|
if (this.archived.some(entry => entry.taskId === taskId))
|
|
179
313
|
return;
|
|
314
|
+
this.counters.trackedTotal += 1;
|
|
180
315
|
this.tasks = [{
|
|
181
316
|
taskId,
|
|
182
317
|
team,
|
|
@@ -185,6 +320,8 @@ export class TaskLedger {
|
|
|
185
320
|
direction: 'outbound',
|
|
186
321
|
...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
|
|
187
322
|
...(brief !== undefined && brief !== '' ? { brief } : {}),
|
|
323
|
+
...(sender !== undefined ? { sender } : {}),
|
|
324
|
+
...birthFacts(peer, birth),
|
|
188
325
|
status: 'pending',
|
|
189
326
|
}, ...this.tasks];
|
|
190
327
|
this.trimOwed();
|
|
@@ -203,7 +340,7 @@ export class TaskLedger {
|
|
|
203
340
|
* @param from - the caller's receipt target (label or callback address).
|
|
204
341
|
* @param contextId - the delivery's conversation id, kept for follow-up routes (empty omits it).
|
|
205
342
|
*/
|
|
206
|
-
trackInbound(taskId, team, from, contextId, receiptExpected, brief) {
|
|
343
|
+
trackInbound(taskId, team, from, contextId, receiptExpected, brief, auth, deliveredTo) {
|
|
207
344
|
if (taskId === '')
|
|
208
345
|
return;
|
|
209
346
|
this.sweep(this.now());
|
|
@@ -211,6 +348,7 @@ export class TaskLedger {
|
|
|
211
348
|
return;
|
|
212
349
|
if (this.archived.some(entry => entry.taskId === taskId))
|
|
213
350
|
return;
|
|
351
|
+
this.counters.trackedTotal += 1;
|
|
214
352
|
this.tasks = [{
|
|
215
353
|
taskId,
|
|
216
354
|
team,
|
|
@@ -220,6 +358,9 @@ export class TaskLedger {
|
|
|
220
358
|
...(receiptExpected === false ? { receiptExpected: false } : {}),
|
|
221
359
|
...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
|
|
222
360
|
...(brief !== undefined && brief !== '' ? { brief } : {}),
|
|
361
|
+
// #114 S2: frozen at birth; a replayed id never reaches here (first record wins above).
|
|
362
|
+
...(auth !== undefined ? { auth } : {}),
|
|
363
|
+
...(deliveredTo !== undefined && deliveredTo !== '' ? { deliveredTo: deliveredTo.slice(0, ROUTING_FACT_CAP) } : {}),
|
|
223
364
|
status: 'pending',
|
|
224
365
|
}, ...this.tasks];
|
|
225
366
|
this.trimOwed();
|
|
@@ -276,6 +417,9 @@ export class TaskLedger {
|
|
|
276
417
|
endedBy: 'abandoned',
|
|
277
418
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
278
419
|
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
420
|
+
...(record.sender !== undefined ? { sender: record.sender } : {}),
|
|
421
|
+
...(record.auth !== undefined ? { auth: record.auth } : {}),
|
|
422
|
+
...routingOf(record),
|
|
279
423
|
});
|
|
280
424
|
this.persist();
|
|
281
425
|
return { outcome: 'cleared', taskId, archivedAt: resolvedAt };
|
|
@@ -318,6 +462,9 @@ export class TaskLedger {
|
|
|
318
462
|
endedBy: 'cancelled',
|
|
319
463
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
320
464
|
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
465
|
+
...(record.sender !== undefined ? { sender: record.sender } : {}),
|
|
466
|
+
...(record.auth !== undefined ? { auth: record.auth } : {}),
|
|
467
|
+
...routingOf(record),
|
|
321
468
|
});
|
|
322
469
|
this.persist();
|
|
323
470
|
return {
|
|
@@ -340,10 +487,12 @@ export class TaskLedger {
|
|
|
340
487
|
* record with the latest outcome. A dead-lettered row revives through this
|
|
341
488
|
* same path when a revived target finally answers.
|
|
342
489
|
* @param message - the inbound or relayed message text.
|
|
490
|
+
* @param only - gh#141: settle the named row only when it passes this test;
|
|
491
|
+
* with a test, a row that fails it or is already archived correlates nothing.
|
|
343
492
|
* @returns what the receipt settled, or `undefined` when the message
|
|
344
493
|
* correlated nothing.
|
|
345
494
|
*/
|
|
346
|
-
resolveFromMessage(message) {
|
|
495
|
+
resolveFromMessage(message, judge, only) {
|
|
347
496
|
const parsed = parseReceipt(message);
|
|
348
497
|
if (parsed === null)
|
|
349
498
|
return undefined;
|
|
@@ -353,7 +502,18 @@ export class TaskLedger {
|
|
|
353
502
|
const elapsedMs = parsed.envelope?.elapsedMs;
|
|
354
503
|
this.sweep(this.now());
|
|
355
504
|
const record = this.tasks.find(entry => entry.taskId === taskId);
|
|
505
|
+
if (only !== undefined && (record === undefined || !only(record)))
|
|
506
|
+
return undefined;
|
|
356
507
|
if (record !== undefined) {
|
|
508
|
+
// #114 S4: judge the settler before the row moves; enforce keeps a refused row owed.
|
|
509
|
+
const verdict = judge?.check(record, false);
|
|
510
|
+
if (verdict !== undefined && !verdict.ok) {
|
|
511
|
+
this.noteRefusal(taskId, judge, verdict.reason, message);
|
|
512
|
+
if (judge.enforce) {
|
|
513
|
+
this.persist();
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
357
517
|
this.tasks = this.tasks.filter(entry => entry.taskId !== taskId);
|
|
358
518
|
this.archiveSettled({
|
|
359
519
|
taskId: record.taskId,
|
|
@@ -369,6 +529,10 @@ export class TaskLedger {
|
|
|
369
529
|
endedBy: 'receipt',
|
|
370
530
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
371
531
|
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
532
|
+
...(record.sender !== undefined ? { sender: record.sender } : {}),
|
|
533
|
+
...(record.auth !== undefined ? { auth: record.auth } : {}),
|
|
534
|
+
...routingOf(record),
|
|
535
|
+
...(verdict !== undefined ? { settledBy: { caller: judge.settler.caller.slice(0, SETTLEMENT_ID_CAP), chain_role: verdict.ok ? verdict.role : 'off-chain' } } : {}),
|
|
372
536
|
});
|
|
373
537
|
this.persist();
|
|
374
538
|
return {
|
|
@@ -385,6 +549,18 @@ export class TaskLedger {
|
|
|
385
549
|
const settled = this.archived.find(entry => entry.taskId === taskId);
|
|
386
550
|
if (settled === undefined)
|
|
387
551
|
return undefined;
|
|
552
|
+
// #114 S4 (review of PR #140 P2-3): a late receipt is judged too. Under
|
|
553
|
+
// enforce, an off-chain one only marks that an answer came late — it
|
|
554
|
+
// never rewrites the settled summary or outcome.
|
|
555
|
+
const lateVerdict = judge?.check(settled, false);
|
|
556
|
+
if (lateVerdict !== undefined && !lateVerdict.ok) {
|
|
557
|
+
this.noteRefusal(taskId, judge, lateVerdict.reason, message);
|
|
558
|
+
if (judge.enforce) {
|
|
559
|
+
this.archived = [{ ...settled, lateReceiptAt: this.now() }, ...this.archived.filter(entry => entry.taskId !== taskId)];
|
|
560
|
+
this.persist();
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
388
564
|
// Orphan isolation: a receipt for an explicitly abandoned row must not
|
|
389
565
|
// rewrite the abandonment outcome — the caller stopped waiting by choice.
|
|
390
566
|
// Record that the target did answer later; keep the decision verbatim and
|
|
@@ -432,14 +608,23 @@ export class TaskLedger {
|
|
|
432
608
|
* @param message - the inbound message text.
|
|
433
609
|
* @returns the task ids this message settled (usually none or one).
|
|
434
610
|
*/
|
|
435
|
-
resolveEcho(message) {
|
|
611
|
+
resolveEcho(message, judge) {
|
|
436
612
|
const settled = [];
|
|
613
|
+
let refused = false;
|
|
437
614
|
this.sweep(this.now());
|
|
438
615
|
for (const record of [...this.tasks]) {
|
|
439
616
|
if (record.status !== 'pending')
|
|
440
617
|
continue;
|
|
441
618
|
if (!message.includes(`task ${record.taskId}`))
|
|
442
619
|
continue;
|
|
620
|
+
// #114 S4: an echo is weak evidence — only an on-chain settler's echo counts under enforce.
|
|
621
|
+
const verdict = judge?.check(record, true);
|
|
622
|
+
if (verdict !== undefined && !verdict.ok) {
|
|
623
|
+
this.noteRefusal(record.taskId, judge, verdict.reason, message);
|
|
624
|
+
refused = true;
|
|
625
|
+
if (judge.enforce)
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
443
628
|
this.tasks = this.tasks.filter(entry => entry.taskId !== record.taskId);
|
|
444
629
|
this.archiveSettled({
|
|
445
630
|
taskId: record.taskId,
|
|
@@ -452,16 +637,72 @@ export class TaskLedger {
|
|
|
452
637
|
endedBy: 'echo',
|
|
453
638
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
454
639
|
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
640
|
+
...(record.sender !== undefined ? { sender: record.sender } : {}),
|
|
641
|
+
...(record.auth !== undefined ? { auth: record.auth } : {}),
|
|
642
|
+
...routingOf(record),
|
|
643
|
+
...(verdict !== undefined ? { settledBy: { caller: judge.settler.caller.slice(0, SETTLEMENT_ID_CAP), chain_role: verdict.ok ? verdict.role : 'off-chain' } } : {}),
|
|
455
644
|
});
|
|
456
645
|
settled.push(record.taskId);
|
|
457
646
|
}
|
|
458
|
-
if (settled.length > 0)
|
|
647
|
+
if (settled.length > 0 || refused)
|
|
459
648
|
this.persist();
|
|
460
649
|
return settled;
|
|
461
650
|
}
|
|
462
|
-
/**
|
|
651
|
+
/** #114 S4: the owed row for a task id, if any (read-only; for the settlement gate). */
|
|
652
|
+
find(taskId) {
|
|
653
|
+
return this.tasks.find(entry => entry.taskId === taskId);
|
|
654
|
+
}
|
|
655
|
+
/** #114 S4: refused / would-be-refused settlements, newest first. */
|
|
656
|
+
settlementRefusals() {
|
|
657
|
+
return [...this.refusals];
|
|
658
|
+
}
|
|
659
|
+
/** #114 S4: every refusal ever noted, including those folded or pushed out of the ring. */
|
|
660
|
+
settlementRefusalsTotal() {
|
|
661
|
+
return this.refusalsTotal;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Record one refused / would-be-refused settlement in the bounded ring
|
|
665
|
+
* (persisted by the caller). A repeat of the same (task, settler, reason)
|
|
666
|
+
* folds into its entry and moves it to the head, so one row's flood cannot
|
|
667
|
+
* push other evidence out.
|
|
668
|
+
*/
|
|
669
|
+
noteRefusal(taskId, judge, reason, message) {
|
|
670
|
+
const head = (message.split('\n')[0] ?? '').slice(0, SUMMARY_CAP);
|
|
671
|
+
const id = taskId.slice(0, SETTLEMENT_ID_CAP);
|
|
672
|
+
const settler = { ...judge.settler, caller: judge.settler.caller.slice(0, SETTLEMENT_ID_CAP) };
|
|
673
|
+
const same = (r) => r.taskId === id && r.settler.caller === settler.caller && r.reason === reason && r.enforced === judge.enforce;
|
|
674
|
+
const prior = this.refusals.find(same);
|
|
675
|
+
this.refusalsTotal += 1;
|
|
676
|
+
this.refusals = [{ taskId: id, at: this.now(), settler, reason, head, enforced: judge.enforce, count: (prior?.count ?? 0) + 1 }, ...this.refusals.filter(r => !same(r))].slice(0, SETTLEMENT_REFUSAL_CAP);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Move one settled record to the archive head, trimming at the cap.
|
|
680
|
+
* gh#154: every arrival and every cap-trimmed row is counted — the trim
|
|
681
|
+
* itself stays exactly as it was, only visible now.
|
|
682
|
+
*/
|
|
463
683
|
archiveSettled(record) {
|
|
464
|
-
|
|
684
|
+
const next = [record, ...this.archived];
|
|
685
|
+
const kept = next.slice(0, this.archiveCap);
|
|
686
|
+
this.counters.archiveTrimmed += next.length - kept.length;
|
|
687
|
+
if (record.endedBy !== undefined)
|
|
688
|
+
this.counters.archivedByEnd[record.endedBy] += 1;
|
|
689
|
+
this.archived = kept;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* gh#154 conservation verdict — duplicates first, then the count identity
|
|
693
|
+
* (a duplicated row can still make the counts balance; the Harness²
|
|
694
|
+
* assert_identity lesson). The counters module answers the count half.
|
|
695
|
+
*/
|
|
696
|
+
conservation() {
|
|
697
|
+
const seen = new Set();
|
|
698
|
+
const duplicateTaskIds = [];
|
|
699
|
+
for (const row of [...this.tasks, ...this.archived]) {
|
|
700
|
+
if (seen.has(row.taskId))
|
|
701
|
+
duplicateTaskIds.push(row.taskId);
|
|
702
|
+
else
|
|
703
|
+
seen.add(row.taskId);
|
|
704
|
+
}
|
|
705
|
+
return this.counters.verdict(this.tasks.length, this.archived.length, duplicateTaskIds);
|
|
465
706
|
}
|
|
466
707
|
/**
|
|
467
708
|
* gh#86 (chief order direct-4f596524): when the owed book exceeds
|
|
@@ -487,6 +728,9 @@ export class TaskLedger {
|
|
|
487
728
|
...(row.brief !== undefined ? { brief: row.brief } : {}),
|
|
488
729
|
endedBy: 'evicted',
|
|
489
730
|
...(row.direction !== undefined ? { direction: row.direction } : {}),
|
|
731
|
+
...(row.sender !== undefined ? { sender: row.sender } : {}),
|
|
732
|
+
...(row.auth !== undefined ? { auth: row.auth } : {}),
|
|
733
|
+
...routingOf(row),
|
|
490
734
|
});
|
|
491
735
|
}
|
|
492
736
|
}
|
|
@@ -514,15 +758,33 @@ export class TaskLedger {
|
|
|
514
758
|
return;
|
|
515
759
|
try {
|
|
516
760
|
const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
517
|
-
|
|
761
|
+
// gh#154 review r3: a non-object OR ARRAY top level, and a missing or
|
|
762
|
+
// non-array required `tasks` member, are malformed snapshots — never a
|
|
763
|
+
// healthy empty ledger (an array is typeof 'object'; absence of the
|
|
764
|
+
// one required member is damage, not an empty legacy snapshot).
|
|
765
|
+
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot) || !Array.isArray(snapshot.tasks)) {
|
|
766
|
+
this.counters.damaged = 'snapshot-shape-invalid';
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
// The optional archive array: present-but-not-an-array stamps damage —
|
|
770
|
+
// but the REQUIRED tasks rows still load (review r4 P1: an early return
|
|
771
|
+
// here would drop parseable owed rows wholesale on the next persist).
|
|
772
|
+
if (snapshot.archived !== undefined && !Array.isArray(snapshot.archived)) {
|
|
773
|
+
this.counters.damaged = 'snapshot-shape-invalid';
|
|
774
|
+
}
|
|
775
|
+
const entries = snapshot.tasks;
|
|
518
776
|
const restored = [];
|
|
519
777
|
if (Array.isArray(entries)) {
|
|
520
778
|
for (const raw of entries) {
|
|
521
779
|
// Unparsed on purpose: the legacy shape carries a 'resolved' status
|
|
522
780
|
// and inline outcome fields that TaskRecord no longer declares.
|
|
523
781
|
const entry = raw;
|
|
524
|
-
if (typeof entry?.taskId !== 'string' || entry.taskId === '')
|
|
782
|
+
if (typeof entry?.taskId !== 'string' || entry.taskId === '') {
|
|
783
|
+
// gh#154 review r3: a malformed row never vanishes into a balanced
|
|
784
|
+
// baseline — the parseable rows still load, the verdict refuses.
|
|
785
|
+
this.counters.damaged = this.counters.damaged ?? 'row-shape-invalid';
|
|
525
786
|
continue;
|
|
787
|
+
}
|
|
526
788
|
// Legacy two-state files carried resolved rows inline; they migrate
|
|
527
789
|
// into the archive so pre-upgrade outcomes survive the upgrade.
|
|
528
790
|
if (entry.status === 'resolved') {
|
|
@@ -540,6 +802,10 @@ export class TaskLedger {
|
|
|
540
802
|
...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
|
|
541
803
|
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
542
804
|
...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
805
|
+
...withSender(entry.sender, entry.direction),
|
|
806
|
+
...withAuth(entry.auth, entry, this.now()),
|
|
807
|
+
...withRouting(entry),
|
|
808
|
+
...withSettledBy(entry.settledBy),
|
|
543
809
|
});
|
|
544
810
|
continue;
|
|
545
811
|
}
|
|
@@ -554,6 +820,10 @@ export class TaskLedger {
|
|
|
554
820
|
// it is unknown, so a restored outbound row must not lose it.
|
|
555
821
|
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
556
822
|
...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
823
|
+
// #114 S3: a malformed sender is dropped whole; the row survives.
|
|
824
|
+
...withSender(entry.sender, entry.direction),
|
|
825
|
+
...withAuth(entry.auth, entry, this.now()),
|
|
826
|
+
...withRouting(entry),
|
|
557
827
|
status: entry.status === 'dead' ? 'dead' : 'pending',
|
|
558
828
|
...(entry.status === 'dead'
|
|
559
829
|
? {
|
|
@@ -568,8 +838,11 @@ export class TaskLedger {
|
|
|
568
838
|
if (Array.isArray(legacyArchive)) {
|
|
569
839
|
for (const raw of legacyArchive) {
|
|
570
840
|
const entry = raw;
|
|
571
|
-
if (typeof entry?.taskId !== 'string' || entry.taskId === '')
|
|
841
|
+
if (typeof entry?.taskId !== 'string' || entry.taskId === '') {
|
|
842
|
+
// gh#154 review r3: same refusal for a malformed archived row.
|
|
843
|
+
this.counters.damaged = this.counters.damaged ?? 'row-shape-invalid';
|
|
572
844
|
continue;
|
|
845
|
+
}
|
|
573
846
|
if (this.archived.some(existing => existing.taskId === entry.taskId))
|
|
574
847
|
continue;
|
|
575
848
|
this.archived.push({
|
|
@@ -586,15 +859,48 @@ export class TaskLedger {
|
|
|
586
859
|
...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
|
|
587
860
|
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
588
861
|
...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
862
|
+
...withSender(entry.sender, entry.direction),
|
|
863
|
+
...withAuth(entry.auth, entry, this.now()),
|
|
864
|
+
...withRouting(entry),
|
|
865
|
+
...withSettledBy(entry.settledBy),
|
|
589
866
|
});
|
|
590
867
|
}
|
|
591
868
|
}
|
|
869
|
+
// gh#154: the load path's cap cuts are COUNTED, never silent. Rows this
|
|
870
|
+
// snapshot carried beyond the caps leave the live view but remain in the
|
|
871
|
+
// identity through restoreTrimmed.
|
|
592
872
|
this.tasks = restored.slice(0, TASK_CAP);
|
|
873
|
+
this.counters.restoreTrimmed += Math.max(0, restored.length - this.tasks.length);
|
|
874
|
+
this.counters.restoreTrimmed += Math.max(0, this.archived.length - this.archiveCap);
|
|
593
875
|
this.archived = this.archived.slice(0, this.archiveCap);
|
|
876
|
+
// gh#154: restore the lifetime counters through the conservation
|
|
877
|
+
// module's classifier - absent block = pre-#154 legacy bootstrap
|
|
878
|
+
// (kept rows + this load's cuts, endedBy via the legacy classifier);
|
|
879
|
+
// present-but-invalid (partial/negative/fractional/null body/
|
|
880
|
+
// unbounded trim) = damage: counters zero, verdict refuses conserved;
|
|
881
|
+
// valid = counters as persisted, a persisted damage marker riding
|
|
882
|
+
// along (review r2: it outlives the restart).
|
|
883
|
+
const classified = classifyConservationBlock(snapshot.conservation);
|
|
884
|
+
if (classified.kind === 'legacy') {
|
|
885
|
+
this.counters.legacyBootstrap(this.tasks.length, this.archived, this.counters.restoreTrimmed, archivedEndedBy);
|
|
886
|
+
}
|
|
887
|
+
else if (classified.kind === 'valid') {
|
|
888
|
+
this.counters.loadFrom(classified.snapshot, this.tasks.length, this.archived.length);
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
this.counters.damaged = classified.reason;
|
|
892
|
+
}
|
|
893
|
+
const refusals = snapshot?.settlementRefusals;
|
|
894
|
+
if (Array.isArray(refusals))
|
|
895
|
+
this.refusals = refusals.map(parseRefusal).filter((r) => r !== undefined).slice(0, SETTLEMENT_REFUSAL_CAP);
|
|
896
|
+
const total = snapshot?.settlementRefusalsTotal;
|
|
897
|
+
this.refusalsTotal = Math.max(typeof total === 'number' && Number.isInteger(total) && total >= 0 ? total : 0, this.refusals.reduce((n, r) => n + r.count, 0));
|
|
594
898
|
}
|
|
595
899
|
catch {
|
|
596
900
|
// A corrupt ledger is not fatal: routing never depends on it; the
|
|
597
|
-
// reconciliation simply starts fresh.
|
|
901
|
+
// reconciliation simply starts fresh. gh#154 review P1: a fresh start
|
|
902
|
+
// is NOT a conserved state — the unreadable history may have held work.
|
|
903
|
+
this.counters.damaged = 'snapshot-unreadable';
|
|
598
904
|
}
|
|
599
905
|
}
|
|
600
906
|
/** Persist the ledger (no-op when no path was configured). */
|
|
@@ -606,6 +912,12 @@ export class TaskLedger {
|
|
|
606
912
|
const snapshot = {
|
|
607
913
|
tasks: this.tasks.map(task => ({ ...task })),
|
|
608
914
|
...(this.archived.length > 0 ? { archived: this.archived.map(entry => ({ ...entry })) } : {}),
|
|
915
|
+
...(this.refusals.length > 0 ? { settlementRefusals: this.refusals.map(entry => ({ ...entry, settler: { ...entry.settler } })) } : {}),
|
|
916
|
+
...(this.refusalsTotal > 0 ? { settlementRefusalsTotal: this.refusalsTotal } : {}),
|
|
917
|
+
// gh#154: the conservation counters ride every snapshot once any work
|
|
918
|
+
// has been tracked (zero-work ledgers stay byte-identical to before).
|
|
919
|
+
// A damage marker rides too — review r2: it must survive restarts.
|
|
920
|
+
...(this.counters.trackedTotal > 0 || this.counters.damaged !== undefined ? { conservation: this.counters.toSnapshot() } : {}),
|
|
609
921
|
};
|
|
610
922
|
writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
|
|
611
923
|
}
|
package/lib/team-store.js
CHANGED
|
@@ -31,6 +31,7 @@ export class TeamMembershipStore {
|
|
|
31
31
|
/** Entries in first-declaration order; teams in join order. */
|
|
32
32
|
entries = [];
|
|
33
33
|
path;
|
|
34
|
+
listeners = new Set();
|
|
34
35
|
/**
|
|
35
36
|
* @param path - the snapshot file; an empty path keeps the store in memory.
|
|
36
37
|
*/
|
|
@@ -98,6 +99,7 @@ export class TeamMembershipStore {
|
|
|
98
99
|
return;
|
|
99
100
|
entry.teams.push(clean);
|
|
100
101
|
this.persist();
|
|
102
|
+
this.notify();
|
|
101
103
|
}
|
|
102
104
|
/** Retract one membership; a no-op when absent. */
|
|
103
105
|
remove(session, team) {
|
|
@@ -110,13 +112,34 @@ export class TeamMembershipStore {
|
|
|
110
112
|
if (entry.teams.length === 0)
|
|
111
113
|
this.entries = this.entries.filter(candidate => candidate.session !== session);
|
|
112
114
|
this.persist();
|
|
115
|
+
this.notify();
|
|
113
116
|
}
|
|
114
117
|
}
|
|
115
118
|
/** Drop every declaration of one session (a left or archived node). */
|
|
116
119
|
dropSession(session) {
|
|
117
120
|
const before = this.entries.length;
|
|
118
121
|
this.entries = this.entries.filter(entry => entry.session !== session);
|
|
119
|
-
if (this.entries.length !== before)
|
|
122
|
+
if (this.entries.length !== before) {
|
|
120
123
|
this.persist();
|
|
124
|
+
this.notify();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* #114 S6: observe changes — the listener runs after every write that
|
|
129
|
+
* changed the store (never on restore or reload). A throwing listener
|
|
130
|
+
* cannot break the write.
|
|
131
|
+
* @returns the unsubscribe function.
|
|
132
|
+
*/
|
|
133
|
+
onChange(listener) {
|
|
134
|
+
this.listeners.add(listener);
|
|
135
|
+
return () => { this.listeners.delete(listener); };
|
|
136
|
+
}
|
|
137
|
+
notify() {
|
|
138
|
+
for (const listener of this.listeners) {
|
|
139
|
+
try {
|
|
140
|
+
listener();
|
|
141
|
+
}
|
|
142
|
+
catch { /* a listener's failure is its own */ }
|
|
143
|
+
}
|
|
121
144
|
}
|
|
122
145
|
}
|