@nelsonlongxiang/dsh-open-a2a-net 0.6.25 → 0.6.27

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.
@@ -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
- this.peers.set(seed, { url: seed, score: INITIAL_SCORE, seed: true });
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. A non-seed's score drops; an
110
- * at-or-below-floor peer is evicted, so a bad referral disappears rather
111
- * than degrading forever. Seeds are config-owned and never penalized.
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 || peer.seed)
148
+ if (peer === undefined)
117
149
  return;
118
- peer.score -= FAILURE_PENALTY;
119
- if (peer.score <= EVICTION_FLOOR)
120
- this.peers.delete(url);
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
  }
@@ -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
- const callerEnded = CALLER_ENDED_PREFIX.test(settled.summary ?? '');
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: '(settled by content echo)',
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
- ...(entry.direction === 'inbound' ? { direction: 'inbound' } : {}),
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. A non-seed's score drops; an
81
- * at-or-below-floor peer is evicted, so a bad referral disappears rather
82
- * than degrading forever. Seeds are config-owned and never penalized.
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). */
@@ -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). */
@@ -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
- const CONTROL_KEY = (window as unknown as { __DSH_A2A_KEY__?: string }).__DSH_A2A_KEY__ ?? ''
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
- return { ok: res.ok && body.ok === true, sessions: body.sessions, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
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
- planning.reconcile({ sessions: allSessions, teams, peerCount: peers.length, peers, inFlight, remoteTeams })
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
  }