@nelsonlongxiang/dsh-open-a2a-net 0.6.25 → 0.6.26
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 +15 -11
- package/README_zh.md +3 -2
- package/assets/nexusDist/assets/index-DszoLz8i.js +5 -0
- package/assets/nexusDist/assets/index-uQf7NwjF.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +300 -0
- package/lib/card.js +17 -0
- package/lib/cli-deposit.js +141 -0
- package/lib/index.js +636 -25
- package/lib/owner-settings.js +100 -0
- package/lib/peer-store.js +65 -9
- package/lib/task-ledger.js +44 -3
- package/lib/types/cli-deposit.d.ts +53 -0
- package/lib/types/owner-settings.d.ts +19 -0
- package/lib/types/peer-store.d.ts +22 -3
- package/lib/types/task-ledger.d.ts +18 -0
- package/lib/types/types.d.ts +10 -0
- package/nexus-stage/src/canvas-ops.ts +2 -2
- package/nexus-stage/src/federation.ts +6 -0
- package/nexus-stage/src/main.ts +97 -4
- package/nexus-stage/src/planning-menus.ts +16 -4
- package/nexus-stage/src/planning-types.ts +3 -0
- package/nexus-stage/src/planning-view.ts +208 -18
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-chat.ts +670 -0
- package/nexus-stage/src/wb-directory.ts +222 -0
- package/nexus-stage/src/wb-ledger.ts +266 -0
- package/nexus-stage/src/wb-rail.ts +147 -0
- package/nexus-stage/src/workbench.css +190 -84
- package/nexus-stage/src/workbench.ts +385 -721
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +214 -218
- package/assets/nexusDist/assets/index-034h1cNc.js +0 -3
- package/assets/nexusDist/assets/index-PG9EieGX.css +0 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-settings persistence (gh#34): the three unlock knobs a remote
|
|
3
|
+
* build-out needs — teamJoinAllowlist / teamScopeInfo / announce — written
|
|
4
|
+
* into the HOME patch layer (DSH_HOME/cordis.patch.yml), the authoritative
|
|
5
|
+
* config surface (the 3081 lesson: the home-level patch wins over the
|
|
6
|
+
* profile layer). A conservative line editor: it locates the a2a entry's
|
|
7
|
+
* config: block and sets exactly these three keys, never touching anything
|
|
8
|
+
* else; a missing entry is appended in the canonical short-id form.
|
|
9
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/owner-settings
|
|
10
|
+
*/
|
|
11
|
+
/** The exact keys this module may touch, in wire order. */
|
|
12
|
+
const KEYS = ['teamJoinAllowlist', 'teamScopeInfo', 'announce'];
|
|
13
|
+
export function applyOwnerSettings(patchYaml, changes) {
|
|
14
|
+
const lines = patchYaml.split('\n');
|
|
15
|
+
// Find the a2a entry: `- id: a2a` / `- id: 'a2a'` variants.
|
|
16
|
+
let entryStart = -1;
|
|
17
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
18
|
+
const m = /^(\s*)-\s*id:\s*'?(a2a)'?\s*$/.exec(lines[i] ?? '');
|
|
19
|
+
if (m !== null) {
|
|
20
|
+
entryStart = i;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
let restartNeeded = false;
|
|
25
|
+
if (changes.announce !== undefined)
|
|
26
|
+
restartNeeded = true;
|
|
27
|
+
const writes = [];
|
|
28
|
+
if (changes.teamJoinAllowlist !== undefined) {
|
|
29
|
+
writes.push(changes.teamJoinAllowlist.length === 0
|
|
30
|
+
? ' teamJoinAllowlist: []'
|
|
31
|
+
: ` teamJoinAllowlist: [${changes.teamJoinAllowlist.map(name => `'${name.replace(/'/g, "''")}'`).join(', ')}]`);
|
|
32
|
+
}
|
|
33
|
+
if (changes.teamScopeInfo !== undefined)
|
|
34
|
+
writes.push(` teamScopeInfo: ${changes.teamScopeInfo ? 'true' : 'false'}`);
|
|
35
|
+
if (changes.announce !== undefined)
|
|
36
|
+
writes.push(` announce: ${changes.announce ? 'true' : 'false'}`);
|
|
37
|
+
if (writes.length === 0)
|
|
38
|
+
return { yaml: patchYaml, restartNeeded };
|
|
39
|
+
if (entryStart === -1) {
|
|
40
|
+
// Append a canonical entry (the home patch is a top-level list).
|
|
41
|
+
const trailing = patchYaml.endsWith('\n') ? '' : '\n';
|
|
42
|
+
return { yaml: `${patchYaml}${trailing}- id: a2a\n config:\n${writes.join('\n')}\n`, restartNeeded };
|
|
43
|
+
}
|
|
44
|
+
// Locate the config: block inside the entry (the entry's own indentation
|
|
45
|
+
// is two spaces under the `- id:` line).
|
|
46
|
+
let i = entryStart + 1;
|
|
47
|
+
let configAt = -1;
|
|
48
|
+
while (i < lines.length) {
|
|
49
|
+
const line = lines[i] ?? '';
|
|
50
|
+
if (/^\S/.test(line))
|
|
51
|
+
break; // next top-level entry
|
|
52
|
+
if (/^\s{2}config:\s*$/.test(line)) {
|
|
53
|
+
configAt = i;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
i += 1;
|
|
57
|
+
}
|
|
58
|
+
if (configAt === -1) {
|
|
59
|
+
// Insert a config: block right after the id line.
|
|
60
|
+
lines.splice(entryStart + 1, 0, ' config:', ...writes);
|
|
61
|
+
return { yaml: lines.join('\n'), restartNeeded };
|
|
62
|
+
}
|
|
63
|
+
// Rewrite exactly our keys inside the block; keep foreign lines verbatim.
|
|
64
|
+
const blockIndent = ' ';
|
|
65
|
+
let insertAt = configAt + 1;
|
|
66
|
+
for (const key of KEYS) {
|
|
67
|
+
let found = false;
|
|
68
|
+
let j = configAt + 1;
|
|
69
|
+
while (j < lines.length) {
|
|
70
|
+
const line = lines[j] ?? '';
|
|
71
|
+
if (/^\S/.test(line) || /^\s{2}\S/.test(line) && !/^\s{4}/.test(line) && !/^\s{2}config:/.test(lines[configAt] ?? '')) {
|
|
72
|
+
if (!line.startsWith(blockIndent) && !/^\s*$/.test(line) && j > configAt) {
|
|
73
|
+
// leaving the block when a line is less indented than the keys
|
|
74
|
+
if (!line.startsWith(' '))
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const kv = new RegExp(`^\\s*${key}:`).exec(line);
|
|
79
|
+
if (kv !== null) {
|
|
80
|
+
const write = writes.find(w => w.trim().startsWith(`${key}:`));
|
|
81
|
+
if (write !== undefined) {
|
|
82
|
+
lines[j] = write;
|
|
83
|
+
found = true;
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (/^\s*-\s*id:/.test(line))
|
|
88
|
+
break;
|
|
89
|
+
j += 1;
|
|
90
|
+
}
|
|
91
|
+
if (!found) {
|
|
92
|
+
const write = writes.find(w => w.trim().startsWith(`${key}:`));
|
|
93
|
+
if (write !== undefined) {
|
|
94
|
+
lines.splice(insertAt, 0, write);
|
|
95
|
+
insertAt += 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { yaml: lines.join('\n'), restartNeeded };
|
|
100
|
+
}
|
package/lib/peer-store.js
CHANGED
|
@@ -48,8 +48,38 @@ export class PeerStore {
|
|
|
48
48
|
// The config is authoritative for a seed: it is (re)marked as one —
|
|
49
49
|
// immune to eviction — and restarts from the initial score regardless
|
|
50
50
|
// of the persisted one. Replacing the record wholesale keeps PeerRecord
|
|
51
|
-
// fields readonly.
|
|
52
|
-
|
|
51
|
+
// fields readonly. The contact stamps are observations, not config:
|
|
52
|
+
// they carry over, so a restart does not blank a seed's lamp (gh#24).
|
|
53
|
+
const prior = this.peers.get(seed);
|
|
54
|
+
this.peers.set(seed, {
|
|
55
|
+
url: seed,
|
|
56
|
+
score: INITIAL_SCORE,
|
|
57
|
+
seed: true,
|
|
58
|
+
...(prior?.lastContactAt !== undefined ? { lastContactAt: prior.lastContactAt } : {}),
|
|
59
|
+
...(prior?.lastContactOk !== undefined ? { lastContactOk: prior.lastContactOk } : {}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// The config is authoritative BOTH ways (board-stale-peer fix, 0.6.26):
|
|
63
|
+
// a persisted record may carry seed:true from an OLD config generation
|
|
64
|
+
// whose operator already removed the URL — left alone, a dead URL keeps
|
|
65
|
+
// its immunity forever and the workbench board shows a peer that can
|
|
66
|
+
// never evict (owner sighting: `.157:13082` survived its config removal
|
|
67
|
+
// for days). Downgrade those to referral-learned, keeping the score so
|
|
68
|
+
// noteFailure can decay and evict them honestly.
|
|
69
|
+
const current = new Set(seeds.filter(seed => seed !== ''));
|
|
70
|
+
for (const [url, peer] of this.peers) {
|
|
71
|
+
if (peer.seed && !current.has(url)) {
|
|
72
|
+
// Downgrade keeps every learned fact (score, session, contact
|
|
73
|
+
// stamps) — only the immunity bit changes.
|
|
74
|
+
this.peers.set(url, {
|
|
75
|
+
url,
|
|
76
|
+
score: peer.score,
|
|
77
|
+
seed: false,
|
|
78
|
+
...(peer.session !== undefined ? { session: peer.session } : {}),
|
|
79
|
+
...(peer.lastContactAt !== undefined ? { lastContactAt: peer.lastContactAt } : {}),
|
|
80
|
+
...(peer.lastContactOk !== undefined ? { lastContactOk: peer.lastContactOk } : {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
53
83
|
}
|
|
54
84
|
}
|
|
55
85
|
/**
|
|
@@ -106,18 +136,24 @@ export class PeerStore {
|
|
|
106
136
|
this.persist();
|
|
107
137
|
}
|
|
108
138
|
/**
|
|
109
|
-
* Note a failed fetch of one peer.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
139
|
+
* Note a failed fetch of one peer. Every peer records the failed attempt
|
|
140
|
+
* (the host-rail lamp turns red for a seed and a learned peer alike,
|
|
141
|
+
* gh#24); only a non-seed's score drops, and an at-or-below-floor peer is
|
|
142
|
+
* evicted, so a bad referral disappears rather than degrading forever.
|
|
143
|
+
* Seeds are config-owned and never penalized.
|
|
112
144
|
* @param url - the peer that failed.
|
|
113
145
|
*/
|
|
114
146
|
noteFailure(url) {
|
|
115
147
|
const peer = this.peers.get(url);
|
|
116
|
-
if (peer === undefined
|
|
148
|
+
if (peer === undefined)
|
|
117
149
|
return;
|
|
118
|
-
peer.
|
|
119
|
-
|
|
120
|
-
|
|
150
|
+
peer.lastContactAt = Date.now();
|
|
151
|
+
peer.lastContactOk = false;
|
|
152
|
+
if (!peer.seed) {
|
|
153
|
+
peer.score -= FAILURE_PENALTY;
|
|
154
|
+
if (peer.score <= EVICTION_FLOOR)
|
|
155
|
+
this.peers.delete(url);
|
|
156
|
+
}
|
|
121
157
|
this.persist();
|
|
122
158
|
}
|
|
123
159
|
/**
|
|
@@ -129,8 +165,24 @@ export class PeerStore {
|
|
|
129
165
|
if (peer === undefined)
|
|
130
166
|
return;
|
|
131
167
|
peer.score += SUCCESS_REWARD;
|
|
168
|
+
peer.lastContactAt = Date.now();
|
|
169
|
+
peer.lastContactOk = true;
|
|
132
170
|
this.persist();
|
|
133
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The epoch ms of the last contact attempt with one tracked peer (#20 W-C
|
|
174
|
+
* host-rail health lamp), or undefined when the store never touched it.
|
|
175
|
+
*/
|
|
176
|
+
lastContact(url) {
|
|
177
|
+
return this.peers.get(url)?.lastContactAt;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Whether the last contact attempt succeeded (gh#24) — undefined when no
|
|
181
|
+
* attempt was ever recorded.
|
|
182
|
+
*/
|
|
183
|
+
lastContactOk(url) {
|
|
184
|
+
return this.peers.get(url)?.lastContactOk;
|
|
185
|
+
}
|
|
134
186
|
/**
|
|
135
187
|
* The current quality score of one tracked peer, for state reporting.
|
|
136
188
|
* @param url - the peer URL.
|
|
@@ -189,6 +241,10 @@ export class PeerStore {
|
|
|
189
241
|
score: typeof peer.score === 'number' && Number.isFinite(peer.score) ? peer.score : INITIAL_SCORE,
|
|
190
242
|
seed: peer.seed === true,
|
|
191
243
|
...(typeof peer.session === 'string' && peer.session !== '' ? { session: peer.session } : {}),
|
|
244
|
+
// gh#24: the contact stamp round-trips (a restart must not forget
|
|
245
|
+
// when we last reached the peer, nor whether it answered).
|
|
246
|
+
...(typeof peer.lastContactAt === 'number' && Number.isFinite(peer.lastContactAt) ? { lastContactAt: peer.lastContactAt } : {}),
|
|
247
|
+
...(typeof peer.lastContactOk === 'boolean' ? { lastContactOk: peer.lastContactOk } : {}),
|
|
192
248
|
});
|
|
193
249
|
}
|
|
194
250
|
}
|
package/lib/task-ledger.js
CHANGED
|
@@ -60,6 +60,29 @@ export function briefOf(message) {
|
|
|
60
60
|
* lazy — every read/write entry point sweeps first.
|
|
61
61
|
*/
|
|
62
62
|
export const TASK_STALE_TTL_MS = 24 * 60 * 60_000;
|
|
63
|
+
const ARCHIVE_ENDS = ['receipt', 'echo', 'abandoned', 'cancelled'];
|
|
64
|
+
/** Summary marker {@link TaskLedger.resolveEcho} writes on echo-settled rows. */
|
|
65
|
+
const ECHO_SUMMARY = '(settled by content echo)';
|
|
66
|
+
/**
|
|
67
|
+
* How one archived row ended (gh#31). The recorded field wins; legacy rows
|
|
68
|
+
* fall back to the summary markers this ledger writes itself. The fallback
|
|
69
|
+
* reads a receipt-settled row's summary, which is the peer's text, so it is
|
|
70
|
+
* only as exact as the legacy data allows.
|
|
71
|
+
* @param row - the archived record.
|
|
72
|
+
* @returns the archive path that ended it.
|
|
73
|
+
*/
|
|
74
|
+
export function archivedEndedBy(row) {
|
|
75
|
+
if (row.endedBy !== undefined)
|
|
76
|
+
return row.endedBy;
|
|
77
|
+
const summary = row.summary ?? '';
|
|
78
|
+
if (summary.startsWith('caller-cancelled'))
|
|
79
|
+
return 'cancelled';
|
|
80
|
+
if (summary.startsWith('caller-abandoned'))
|
|
81
|
+
return 'abandoned';
|
|
82
|
+
if (summary === ECHO_SUMMARY)
|
|
83
|
+
return 'echo';
|
|
84
|
+
return 'receipt';
|
|
85
|
+
}
|
|
63
86
|
/**
|
|
64
87
|
* Bounded outbound-task ledger with whole-file persistence. Pure of any
|
|
65
88
|
* routing knowledge: callers {@link track} a task when a route leaves it
|
|
@@ -247,6 +270,8 @@ export class TaskLedger {
|
|
|
247
270
|
resolvedAt,
|
|
248
271
|
summary: `caller-abandoned${cause}`,
|
|
249
272
|
outcome: 'abandoned',
|
|
273
|
+
endedBy: 'abandoned',
|
|
274
|
+
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
250
275
|
});
|
|
251
276
|
this.persist();
|
|
252
277
|
return { outcome: 'cleared', taskId, archivedAt: resolvedAt };
|
|
@@ -286,6 +311,8 @@ export class TaskLedger {
|
|
|
286
311
|
resolvedAt,
|
|
287
312
|
summary: `caller-cancelled${cause}`,
|
|
288
313
|
outcome: 'abandoned',
|
|
314
|
+
endedBy: 'cancelled',
|
|
315
|
+
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
289
316
|
});
|
|
290
317
|
this.persist();
|
|
291
318
|
return {
|
|
@@ -334,6 +361,8 @@ export class TaskLedger {
|
|
|
334
361
|
...(summary !== '' ? { summary } : {}),
|
|
335
362
|
...(envelopeOutcome !== undefined ? { outcome: envelopeOutcome } : {}),
|
|
336
363
|
...(elapsedMs !== undefined ? { elapsedMs } : {}),
|
|
364
|
+
endedBy: 'receipt',
|
|
365
|
+
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
337
366
|
});
|
|
338
367
|
this.persist();
|
|
339
368
|
return {
|
|
@@ -354,7 +383,11 @@ export class TaskLedger {
|
|
|
354
383
|
// rewrite the abandonment outcome — the caller stopped waiting by choice.
|
|
355
384
|
// Record that the target did answer later; keep the decision verbatim and
|
|
356
385
|
// pin the projected verdict to 'abandoned'.
|
|
357
|
-
|
|
386
|
+
// gh#31: the recorded end wins; the summary prefix only decides legacy
|
|
387
|
+
// rows (a receipt-settled row's summary is the peer's own text).
|
|
388
|
+
const callerEnded = settled.endedBy !== undefined
|
|
389
|
+
? settled.endedBy === 'abandoned' || settled.endedBy === 'cancelled'
|
|
390
|
+
: CALLER_ENDED_PREFIX.test(settled.summary ?? '');
|
|
358
391
|
this.archived = [
|
|
359
392
|
{
|
|
360
393
|
...settled,
|
|
@@ -409,7 +442,9 @@ export class TaskLedger {
|
|
|
409
442
|
startedAt: record.startedAt,
|
|
410
443
|
...(record.contextId !== undefined ? { contextId: record.contextId } : {}),
|
|
411
444
|
resolvedAt: this.now(),
|
|
412
|
-
summary:
|
|
445
|
+
summary: ECHO_SUMMARY,
|
|
446
|
+
endedBy: 'echo',
|
|
447
|
+
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
413
448
|
});
|
|
414
449
|
settled.push(record.taskId);
|
|
415
450
|
}
|
|
@@ -468,6 +503,8 @@ export class TaskLedger {
|
|
|
468
503
|
...(typeof entry.summary === 'string' && entry.summary !== '' ? { summary: entry.summary } : {}),
|
|
469
504
|
...(typeof entry.outcome === 'string' && RECEIPT_OUTCOMES.includes(entry.outcome) ? { outcome: entry.outcome } : {}),
|
|
470
505
|
...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
|
|
506
|
+
...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
|
|
507
|
+
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
471
508
|
});
|
|
472
509
|
continue;
|
|
473
510
|
}
|
|
@@ -478,7 +515,9 @@ export class TaskLedger {
|
|
|
478
515
|
startedAt: typeof entry.startedAt === 'number' && Number.isFinite(entry.startedAt) ? entry.startedAt : this.now(),
|
|
479
516
|
...(typeof entry.contextId === 'string' && entry.contextId !== '' ? { contextId: entry.contextId } : {}),
|
|
480
517
|
...(typeof entry.brief === 'string' && entry.brief !== '' ? { brief: entry.brief } : {}),
|
|
481
|
-
|
|
518
|
+
// gh#31: both sides round-trip — /state omits direction only when
|
|
519
|
+
// it is unknown, so a restored outbound row must not lose it.
|
|
520
|
+
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
482
521
|
...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
483
522
|
status: entry.status === 'dead' ? 'dead' : 'pending',
|
|
484
523
|
...(entry.status === 'dead'
|
|
@@ -509,6 +548,8 @@ export class TaskLedger {
|
|
|
509
548
|
...(typeof entry.summary === 'string' && entry.summary !== '' ? { summary: entry.summary } : {}),
|
|
510
549
|
...(typeof entry.outcome === 'string' && RECEIPT_OUTCOMES.includes(entry.outcome) ? { outcome: entry.outcome } : {}),
|
|
511
550
|
...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
|
|
551
|
+
...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
|
|
552
|
+
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
512
553
|
});
|
|
513
554
|
}
|
|
514
555
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Owner ruling ③: the default AND maximum online window. */
|
|
2
|
+
export declare const CLI_DEPOSIT_TTL_MS: number;
|
|
3
|
+
/** Owner ruling ①: <agent-name>/<uuid·8B>. The name is [a-z0-9-]{1,32}. */
|
|
4
|
+
export declare const CLI_HANDLE_RE: RegExp;
|
|
5
|
+
/** One deposited CLI identity. */
|
|
6
|
+
export interface CliDeposit {
|
|
7
|
+
readonly handle: string;
|
|
8
|
+
/** The bare agent-name zone (card.team). */
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/** The CLI's signed card, verbatim wire JSON (served back as-is). */
|
|
11
|
+
readonly cardJson: string;
|
|
12
|
+
readonly depositedAt: number;
|
|
13
|
+
readonly expiresAt: number;
|
|
14
|
+
}
|
|
15
|
+
/** One mailbox entry for a deposited handle. */
|
|
16
|
+
export interface CliMail {
|
|
17
|
+
readonly at: number;
|
|
18
|
+
readonly from: string;
|
|
19
|
+
readonly text: string;
|
|
20
|
+
}
|
|
21
|
+
export declare class CliDepositStore {
|
|
22
|
+
private readonly path;
|
|
23
|
+
private deposits;
|
|
24
|
+
private mailbox;
|
|
25
|
+
private now;
|
|
26
|
+
constructor(path: string, options?: {
|
|
27
|
+
now?: () => number;
|
|
28
|
+
});
|
|
29
|
+
/** Deposit (or refresh) one CLI card. TTL is clamped to the 4h ruling. */
|
|
30
|
+
deposit(cardJson: string): {
|
|
31
|
+
ok: true;
|
|
32
|
+
handle: string;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
error: string;
|
|
37
|
+
};
|
|
38
|
+
revoke(handle: string): boolean;
|
|
39
|
+
/** Active deposits (sweeping expired ones — ruling ③ auto-deregister). */
|
|
40
|
+
list(): readonly CliDeposit[];
|
|
41
|
+
get(handle: string): CliDeposit | undefined;
|
|
42
|
+
/** Card-serve rows: the handle + display name, marked cli (no wake). */
|
|
43
|
+
cardRows(): Array<{
|
|
44
|
+
team: string;
|
|
45
|
+
name: string;
|
|
46
|
+
description: string;
|
|
47
|
+
}>;
|
|
48
|
+
mail(handle: string, from: string, text: string): void;
|
|
49
|
+
readMail(handle: string): readonly CliMail[];
|
|
50
|
+
private sweep;
|
|
51
|
+
private persist;
|
|
52
|
+
private restore;
|
|
53
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-settings persistence (gh#34): the three unlock knobs a remote
|
|
3
|
+
* build-out needs — teamJoinAllowlist / teamScopeInfo / announce — written
|
|
4
|
+
* into the HOME patch layer (DSH_HOME/cordis.patch.yml), the authoritative
|
|
5
|
+
* config surface (the 3081 lesson: the home-level patch wins over the
|
|
6
|
+
* profile layer). A conservative line editor: it locates the a2a entry's
|
|
7
|
+
* config: block and sets exactly these three keys, never touching anything
|
|
8
|
+
* else; a missing entry is appended in the canonical short-id form.
|
|
9
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/owner-settings
|
|
10
|
+
*/
|
|
11
|
+
export interface OwnerSettings {
|
|
12
|
+
teamJoinAllowlist?: string[];
|
|
13
|
+
teamScopeInfo?: boolean;
|
|
14
|
+
announce?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function applyOwnerSettings(patchYaml: string, changes: OwnerSettings): {
|
|
17
|
+
yaml: string;
|
|
18
|
+
restartNeeded: boolean;
|
|
19
|
+
};
|
|
@@ -25,6 +25,13 @@ export interface PeerRecord {
|
|
|
25
25
|
readonly seed: boolean;
|
|
26
26
|
/** The signed-card session the peer served last, when a fetch revealed it. */
|
|
27
27
|
readonly session?: string;
|
|
28
|
+
/** Epoch ms of the last contact attempt (success or failure) — the host
|
|
29
|
+
* rail's health lamp reads the recency of this (#20 W-C). */
|
|
30
|
+
lastContactAt?: number;
|
|
31
|
+
/** Whether that last attempt SUCCEEDED (gh#24): the lamp distinguishes a
|
|
32
|
+
* fresh success (green) from a fresh failure (red); recency alone made
|
|
33
|
+
* a dead peer glow green. */
|
|
34
|
+
lastContactOk?: boolean;
|
|
28
35
|
}
|
|
29
36
|
/** The persisted store document (whole-file read/write, small by design). */
|
|
30
37
|
export interface PeerStoreSnapshot {
|
|
@@ -77,9 +84,11 @@ export declare class PeerStore {
|
|
|
77
84
|
*/
|
|
78
85
|
drop(url: string): void;
|
|
79
86
|
/**
|
|
80
|
-
* Note a failed fetch of one peer.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
87
|
+
* Note a failed fetch of one peer. Every peer records the failed attempt
|
|
88
|
+
* (the host-rail lamp turns red for a seed and a learned peer alike,
|
|
89
|
+
* gh#24); only a non-seed's score drops, and an at-or-below-floor peer is
|
|
90
|
+
* evicted, so a bad referral disappears rather than degrading forever.
|
|
91
|
+
* Seeds are config-owned and never penalized.
|
|
83
92
|
* @param url - the peer that failed.
|
|
84
93
|
*/
|
|
85
94
|
noteFailure(url: string): void;
|
|
@@ -88,6 +97,16 @@ export declare class PeerStore {
|
|
|
88
97
|
* @param url - the peer that answered with a verified card.
|
|
89
98
|
*/
|
|
90
99
|
noteSuccess(url: string): void;
|
|
100
|
+
/**
|
|
101
|
+
* The epoch ms of the last contact attempt with one tracked peer (#20 W-C
|
|
102
|
+
* host-rail health lamp), or undefined when the store never touched it.
|
|
103
|
+
*/
|
|
104
|
+
lastContact(url: string): number | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Whether the last contact attempt succeeded (gh#24) — undefined when no
|
|
107
|
+
* attempt was ever recorded.
|
|
108
|
+
*/
|
|
109
|
+
lastContactOk(url: string): boolean | undefined;
|
|
91
110
|
/**
|
|
92
111
|
* The current quality score of one tracked peer, for state reporting.
|
|
93
112
|
* @param url - the peer URL.
|
|
@@ -52,6 +52,8 @@ export declare const TASK_STALE_TTL_MS: number;
|
|
|
52
52
|
export type TaskStatus = 'pending' | 'dead';
|
|
53
53
|
/** F5 layer-3: why a dead row aged out (stamped by the sweep at flip time). */
|
|
54
54
|
export type DeadReason = 'fire-forget' | 'unconsumed';
|
|
55
|
+
/** gh#31: which archive path ended a row — a receipt, a content echo, or the caller. */
|
|
56
|
+
export type ArchiveEnd = 'receipt' | 'echo' | 'abandoned' | 'cancelled';
|
|
55
57
|
/** One tracked outbound task still in the owed book (unsettled). */
|
|
56
58
|
export interface TaskRecord {
|
|
57
59
|
readonly taskId: string;
|
|
@@ -111,7 +113,23 @@ export interface ArchivedRecord {
|
|
|
111
113
|
* recorded as metadata only, never promoted to resolution.
|
|
112
114
|
*/
|
|
113
115
|
readonly lateReceiptAt?: number;
|
|
116
|
+
/**
|
|
117
|
+
* gh#31: which archive path ended the row, recorded at archive time. Absent
|
|
118
|
+
* on rows archived before the field — {@link archivedEndedBy} derives it.
|
|
119
|
+
*/
|
|
120
|
+
readonly endedBy?: ArchiveEnd;
|
|
121
|
+
/** gh#31: which side of the wire the row served, copied from the owed row (absent on rows archived before the field). */
|
|
122
|
+
readonly direction?: 'inbound' | 'outbound';
|
|
114
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* How one archived row ended (gh#31). The recorded field wins; legacy rows
|
|
126
|
+
* fall back to the summary markers this ledger writes itself. The fallback
|
|
127
|
+
* reads a receipt-settled row's summary, which is the peer's text, so it is
|
|
128
|
+
* only as exact as the legacy data allows.
|
|
129
|
+
* @param row - the archived record.
|
|
130
|
+
* @returns the archive path that ended it.
|
|
131
|
+
*/
|
|
132
|
+
export declare function archivedEndedBy(row: ArchivedRecord): ArchiveEnd;
|
|
115
133
|
/** Result of an explicit caller-side abandon. Total and never-thrown. */
|
|
116
134
|
export type AbandonOutcome = 'cleared' | 'already-terminal' | 'unknown';
|
|
117
135
|
/** Structured verdict for one {@link TaskLedger.abandon} call (consumer contract: idempotent). */
|
package/lib/types/types.d.ts
CHANGED
|
@@ -21,6 +21,16 @@ export interface A2aPeerCard {
|
|
|
21
21
|
* not by this card's signature.
|
|
22
22
|
*/
|
|
23
23
|
readonly peers?: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* gh#41 fleet planning: this host's own canvas-team organization, served
|
|
26
|
+
* fresh on the FULL card (linked callers only) — each entry names a team
|
|
27
|
+
* and its member handles so any linked peer's board can render the org.
|
|
28
|
+
*/
|
|
29
|
+
readonly canvasOrg?: ReadonlyArray<{
|
|
30
|
+
name: string;
|
|
31
|
+
team: string;
|
|
32
|
+
members: readonly string[];
|
|
33
|
+
}>;
|
|
24
34
|
/**
|
|
25
35
|
* Joined session teams this peer currently dispatches. Unsigned and
|
|
26
36
|
* optional: served fresh at read time, each entry is a team name the
|
|
@@ -20,8 +20,8 @@ export type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
|
20
20
|
/** The five user-facing write actions (one per design.md §3.3 row), plus the two network join/leave actions the panel-slim migration moved into the planning view. */
|
|
21
21
|
export type CanvasAction =
|
|
22
22
|
| { type: 'create-team'; name: string; ids: readonly string[]; /** true when the model had no such team before this action (a serial failure then leaves a ghost EMPTY team on the host — the wire compensates with a remove). */ created?: boolean }
|
|
23
|
-
| { type: 'add-member'; team: string; ids: readonly string[] }
|
|
24
|
-
| { type: 'remove-member'; team: string; ids: readonly string[] }
|
|
23
|
+
| { type: 'add-member'; team: string; ids: readonly string[]; /** gh#41: set when the target frame is a REMOTE peer's team — the wire routes the op through the host fleet relay. */ fleetPeer?: string }
|
|
24
|
+
| { type: 'remove-member'; team: string; ids: readonly string[]; /** gh#41: see add-member. */ fleetPeer?: string }
|
|
25
25
|
| { type: 'remove-team'; name: string }
|
|
26
26
|
/** `ops` comes from reorderOps() at emit time; the wire stays model-free. */
|
|
27
27
|
| { type: 'reorder'; team: string; ops: ReadonlyArray<RosterOp> }
|
|
@@ -26,6 +26,12 @@ export interface InFlightRow {
|
|
|
26
26
|
export interface PeerRow {
|
|
27
27
|
url: string
|
|
28
28
|
score?: number
|
|
29
|
+
/** gh#20 W-A: this host's link view — 'linked' (operator-established
|
|
30
|
+
* basis holds) | 'stranger' (no link); absent = the host predates the
|
|
31
|
+
* field (render neutral). */
|
|
32
|
+
link?: 'linked' | 'stranger'
|
|
33
|
+
/** Signature-valid cached card held (the badge tick/cross). */
|
|
34
|
+
verified?: boolean
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** A team frame's titlebar anchor {fx + fw/2, fy + 11} (edges.ts lockstep). */
|
package/nexus-stage/src/main.ts
CHANGED
|
@@ -19,10 +19,52 @@ const app = document.getElementById('app')!
|
|
|
19
19
|
// gh#9 §10: the host injects the control-plane key into the shell when
|
|
20
20
|
// apiKey is configured — stamp it on every /__dsh_a2a write so the GUI
|
|
21
21
|
// survives a keyed host (same-origin disclosure only, see the serve site).
|
|
22
|
-
|
|
22
|
+
// gh#40: a REMOTE face (LAN-bound test host, tunnel) serves the shell
|
|
23
|
+
// WITHOUT the injected key by design — the credential must not leak to any
|
|
24
|
+
// network fetcher. The operator still needs the panel there, so the key
|
|
25
|
+
// can be typed once: sessionStorage is browser-local (never transmitted in
|
|
26
|
+
// the page), stamped on the same X-API-Key header thereafter.
|
|
27
|
+
const storedKey = (() => { try { return window.sessionStorage.getItem('dsh_a2a_key') ?? '' } catch { return '' } })()
|
|
28
|
+
const CONTROL_KEY = (window as unknown as { __DSH_A2A_KEY__?: string }).__DSH_A2A_KEY__ ?? storedKey
|
|
23
29
|
const controlFetch = (path: string, init: RequestInit = {}): Promise<Response> =>
|
|
24
30
|
fetch(path, { ...init, headers: { ...(init.headers as Record<string, string> | undefined ?? {}), ...(CONTROL_KEY !== '' ? { 'X-API-Key': CONTROL_KEY } : {}) } })
|
|
25
31
|
|
|
32
|
+
// gh#40: key-entry affordance — mounts BEFORE the panel when the shell came
|
|
33
|
+
// keyless (remote face) and the control plane answers 401. One input, one
|
|
34
|
+
// submit; the stored key survives until the tab closes.
|
|
35
|
+
if (CONTROL_KEY === '') {
|
|
36
|
+
void controlFetch('/__dsh_a2a/owner-settings').then(res => {
|
|
37
|
+
if (res.status !== 401) return
|
|
38
|
+
const gate = document.createElement('div')
|
|
39
|
+
gate.className = 'nexus-keygate'
|
|
40
|
+
gate.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#fff;z-index:9999;font:14px/1.5 system-ui,sans-serif;color:#000'
|
|
41
|
+
const label = document.createElement('div')
|
|
42
|
+
label.textContent = '此面板经远程面打开(密钥不随页面下发)——输入控制密钥'
|
|
43
|
+
const input = document.createElement('input')
|
|
44
|
+
input.type = 'password'
|
|
45
|
+
input.placeholder = 'X-API-Key'
|
|
46
|
+
input.setAttribute('aria-label', '控制密钥')
|
|
47
|
+
const submit = document.createElement('button')
|
|
48
|
+
submit.type = 'button'
|
|
49
|
+
submit.textContent = '进入面板'
|
|
50
|
+
const err = document.createElement('div')
|
|
51
|
+
err.style.color = '#b00'
|
|
52
|
+
const tryKey = (): void => {
|
|
53
|
+
const v = input.value.trim()
|
|
54
|
+
if (v === '') return
|
|
55
|
+
void fetch('/__dsh_a2a/owner-settings', { headers: { 'X-API-Key': v } }).then(r => {
|
|
56
|
+
if (r.status === 401) { err.textContent = '密钥不对,再试一次'; return }
|
|
57
|
+
try { window.sessionStorage.setItem('dsh_a2a_key', v) } catch { /* blocked site data: keep in-page */ }
|
|
58
|
+
window.location.reload()
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
submit.addEventListener('click', tryKey)
|
|
62
|
+
input.addEventListener('keydown', ev => { if (ev.key === 'Enter') tryKey() })
|
|
63
|
+
gate.append(label, input, submit, err)
|
|
64
|
+
document.body.appendChild(gate)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
26
68
|
const faultBadge = document.createElement('div')
|
|
27
69
|
faultBadge.className = 'nexus-fault'
|
|
28
70
|
app.appendChild(faultBadge)
|
|
@@ -113,6 +155,23 @@ const canvasWire = createCanvasWire({
|
|
|
113
155
|
})
|
|
114
156
|
|
|
115
157
|
function runCanvasAction(a: CanvasAction): Promise<boolean> {
|
|
158
|
+
// gh#41: a remote-frame drop forks to the fleet relay — THIS host signs
|
|
159
|
+
// the envelope and forwards; the peer's link gate authorizes the write.
|
|
160
|
+
if ((a.type === 'add-member' || a.type === 'remove-member') && a.fleetPeer !== undefined) {
|
|
161
|
+
return (async () => {
|
|
162
|
+
for (const id of a.ids) {
|
|
163
|
+
const r = await controlFetch('/__dsh_a2a/fleet-canvas-op', {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({ peer: a.fleetPeer, op: a.type === 'add-member' ? 'add-member' : 'remove-member', name: a.team, id }),
|
|
167
|
+
cache: 'no-store',
|
|
168
|
+
}).catch(() => undefined)
|
|
169
|
+
const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
|
|
170
|
+
if (body?.ok !== true) return false
|
|
171
|
+
}
|
|
172
|
+
return true
|
|
173
|
+
})()
|
|
174
|
+
}
|
|
116
175
|
switch (a.type) {
|
|
117
176
|
case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
|
|
118
177
|
case 'add-member': return canvasWire.addMembers(a.team, a.ids)
|
|
@@ -154,6 +213,27 @@ async function fetchLayout(): Promise<unknown> {
|
|
|
154
213
|
// ─── The workbench ───
|
|
155
214
|
const bench = createWorkbench({
|
|
156
215
|
now: () => Date.now(),
|
|
216
|
+
// gh#34: the owner three-key panel rides the control face.
|
|
217
|
+
async ownerSettingsRead() {
|
|
218
|
+
try {
|
|
219
|
+
const res = await controlFetch('/__dsh_a2a/owner-settings')
|
|
220
|
+
return await res.json()
|
|
221
|
+
} catch {
|
|
222
|
+
return { ok: false, error: 'unreachable' }
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
async ownerSettingsWrite(changes) {
|
|
226
|
+
try {
|
|
227
|
+
const res = await controlFetch('/__dsh_a2a/owner-settings', {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'content-type': 'application/json' },
|
|
230
|
+
body: JSON.stringify(changes),
|
|
231
|
+
})
|
|
232
|
+
return await res.json()
|
|
233
|
+
} catch {
|
|
234
|
+
return { ok: false, error: 'unreachable' }
|
|
235
|
+
}
|
|
236
|
+
},
|
|
157
237
|
async abandon(taskId, reason) {
|
|
158
238
|
try {
|
|
159
239
|
const res = await controlFetch('/__dsh_a2a/tasks/abandon', {
|
|
@@ -231,8 +311,10 @@ const bench = createWorkbench({
|
|
|
231
311
|
// sessions included; the network directory is not the reading gate.
|
|
232
312
|
try {
|
|
233
313
|
const res = await controlFetch('/__dsh_a2a/session-log')
|
|
234
|
-
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string }
|
|
235
|
-
|
|
314
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }
|
|
315
|
+
// count: how many logs the host holds — the list face caps at 50, so the
|
|
316
|
+
// directory can say it is showing 50 of N instead of silently truncating.
|
|
317
|
+
return { ok: res.ok && body.ok === true, sessions: body.sessions, count: typeof body.count === 'number' ? body.count : undefined, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
|
|
236
318
|
} catch (error) {
|
|
237
319
|
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
238
320
|
}
|
|
@@ -346,7 +428,18 @@ async function poll(): Promise<void> {
|
|
|
346
428
|
...(typeof r.origin === 'string' && r.origin !== '' ? { origin: r.origin } : {}),
|
|
347
429
|
...(Array.isArray(r.teams) && r.teams.length > 0 ? { teams: r.teams.filter((t): t is string => typeof t === 'string' && t !== '') } : {}),
|
|
348
430
|
}))
|
|
349
|
-
|
|
431
|
+
// gh#41: the peers' published canvas org rides the board (remote frames).
|
|
432
|
+
const peerOrg = (body.peerOrg as unknown[] ?? [])
|
|
433
|
+
.filter((r): r is { url?: unknown; org?: unknown } => r !== null && typeof r === 'object')
|
|
434
|
+
.filter(r => typeof r.url === 'string' && Array.isArray(r.org))
|
|
435
|
+
.map(r => ({
|
|
436
|
+
url: r.url as string,
|
|
437
|
+
org: (r.org as unknown[])
|
|
438
|
+
.filter((e): e is { name?: unknown; team?: unknown; members?: unknown } => e !== null && typeof e === 'object')
|
|
439
|
+
.filter(e => typeof e.team === 'string' && e.team !== '')
|
|
440
|
+
.map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: e.team as string, members: Array.isArray(e.members) ? e.members.filter((m): m is string => typeof m === 'string') : [] })),
|
|
441
|
+
}))
|
|
442
|
+
planning.reconcile({ sessions: allSessions, teams, peerCount: peers.length, peers, inFlight, remoteTeams, peerOrg })
|
|
350
443
|
} catch (err) {
|
|
351
444
|
fault(`state 轮询失败:${String(err)}`)
|
|
352
445
|
}
|