@nelsonlongxiang/dsh-open-a2a-net 0.6.35 → 0.6.37
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 +5 -2
- package/README_zh.md +5 -2
- package/assets/nexusDist/assets/index-C7HagaGy.css +1 -0
- package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +1 -1
- package/lib/a2a-client.js +3 -3
- package/lib/client.js +63 -63
- package/lib/faces/admin.js +23 -5
- package/lib/faces/canvas-notify.js +8 -6
- package/lib/faces/wire.js +11 -5
- package/lib/index.js +35 -10
- package/lib/origin-claim.js +30 -7
- package/lib/owner-settings.js +38 -1
- package/lib/peer-link-display.js +15 -0
- package/lib/queue-guard.js +3 -3
- package/lib/skill/references/cli.md +4 -0
- package/lib/skill/references/routing.md +4 -0
- package/lib/skill/references/setup.md +4 -0
- package/lib/task-ledger-rows.js +192 -0
- package/lib/task-ledger.js +11 -189
- package/lib/tools/info.js +7 -3
- package/lib/types/a2a-client.d.ts +1 -0
- package/lib/types/faces/admin.d.ts +1 -0
- package/lib/types/faces/canvas-notify.d.ts +3 -1
- package/lib/types/faces/wire.d.ts +1 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/origin-claim.d.ts +12 -3
- package/lib/types/owner-settings.d.ts +5 -0
- package/lib/types/peer-link-display.d.ts +13 -0
- package/lib/types/queue-guard.d.ts +3 -2
- package/lib/types/task-ledger-rows.d.ts +341 -0
- package/lib/types/task-ledger.d.ts +6 -284
- package/lib/types/tools/info.d.ts +2 -0
- package/lib/types/types.d.ts +2 -1
- package/nexus-stage/scripts/mockhost.py +14 -6
- package/nexus-stage/src/planning-card.ts +5 -3
- package/nexus-stage/src/planning-member-handles.ts +28 -1
- package/nexus-stage/src/planning-menus.ts +12 -5
- package/nexus-stage/src/planning-view.ts +26 -16
- package/nexus-stage/src/seat-name-editor.ts +31 -0
- package/nexus-stage/src/session-drag.ts +64 -0
- package/nexus-stage/src/wb-directory.ts +2 -0
- package/nexus-stage/src/workbench.css +2 -0
- package/nexus-stage/src/workbench.ts +16 -5
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-Du2E6DJL.js +0 -9
- package/assets/nexusDist/assets/index-z33lHtLw.css +0 -1
|
@@ -18,290 +18,10 @@
|
|
|
18
18
|
* illusion: settlements vanished while debts piled up).
|
|
19
19
|
* @module @nelsonlongxiang/dsh-open-a2a-net/task-ledger
|
|
20
20
|
*/
|
|
21
|
-
import
|
|
22
|
-
import { type
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
export declare const TASK_CAP = 64;
|
|
26
|
-
/** Hard cap on the settled archive (most recent kept). */
|
|
27
|
-
export declare const ARCHIVE_CAP = 64;
|
|
28
|
-
/** Longest receipt summary kept on a record (multi-line outcomes collapse). */
|
|
29
|
-
export declare const SUMMARY_CAP = 200;
|
|
30
|
-
/**
|
|
31
|
-
* Longest dispatch brief kept per row. A ledger row without its payload is
|
|
32
|
-
* unreadable to supervision — "direct-8f3a1c2b → dsh/ec4ce7f8, 3h old" says
|
|
33
|
-
* nothing about whether to nudge, abandon, or read the receipt. The brief is
|
|
34
|
-
* the first non-empty line of the message, capped; the full body never enters
|
|
35
|
-
* the ledger (it can be arbitrarily large and may quote another session's log).
|
|
36
|
-
*/
|
|
37
|
-
export declare const BRIEF_CAP = 120;
|
|
38
|
-
/** Reduce a dispatch body to one readable line: first non-empty line, collapsed, capped. */
|
|
39
|
-
export declare function briefOf(message: string | undefined): string | undefined;
|
|
40
|
-
/**
|
|
41
|
-
* Age at which an unsettled task auto-dead-letters. A dead row exits the
|
|
42
|
-
* owed view, disarms its async-nudge retry (a cold session cannot consume a
|
|
43
|
-
* re-steer anyway), and still accepts a late receipt from a revived target.
|
|
44
|
-
* Injectable per instance so tests can use short windows; sweeping stays
|
|
45
|
-
* lazy — every read/write entry point sweeps first.
|
|
46
|
-
*/
|
|
47
|
-
export declare const TASK_STALE_TTL_MS: number;
|
|
48
|
-
/**
|
|
49
|
-
* The receipt correlation template now lives in `receipt.ts` (shared codec
|
|
50
|
-
* for header + v2 envelope line). This ledger consumes `parseReceipt` so
|
|
51
|
-
* human summaries and machine projections can never drift apart again.
|
|
52
|
-
*/
|
|
53
|
-
/** Lifecycle of a tracked outbound task inside the owed book. */
|
|
54
|
-
export type TaskStatus = 'pending' | 'dead';
|
|
55
|
-
/** F5 layer-3: why a dead row aged out (stamped by the sweep at flip time). */
|
|
56
|
-
export type DeadReason = 'fire-forget' | 'unconsumed';
|
|
57
|
-
/** gh#31: which archive path ended a row — a receipt, a content echo, or the caller. */
|
|
58
|
-
export type ArchiveEnd = 'receipt' | 'echo' | 'abandoned' | 'cancelled' | 'evicted';
|
|
59
|
-
/**
|
|
60
|
-
* #114 S3: who really sent one outbound task — the routing session, the
|
|
61
|
-
* agent-less host face, or a native-teams bridge submitter. Before this
|
|
62
|
-
* field every outbound row was attributable only to the host ("home"), so
|
|
63
|
-
* same-host seats could not be told apart.
|
|
64
|
-
*/
|
|
65
|
-
export interface Sender {
|
|
66
|
-
readonly kind: 'session' | 'host' | 'bridge' | 'cli';
|
|
67
|
-
/**
|
|
68
|
-
* A session's `<zone>/<id8>` node team, the host process label, or the
|
|
69
|
-
* bridge submitter. Group senders by `kind:id`, never by `id` alone: a host
|
|
70
|
-
* label set by hand may look like a session handle.
|
|
71
|
-
*/
|
|
72
|
-
readonly id: string;
|
|
73
|
-
/**
|
|
74
|
-
* Host senders only: the label the call borrowed on the wire when it
|
|
75
|
-
* differs from {@link id} (quick dispatch rides a live joined session's
|
|
76
|
-
* team). Display context, never a bucket key.
|
|
77
|
-
*/
|
|
78
|
-
readonly face?: string;
|
|
79
|
-
}
|
|
80
|
-
/** Longest sender id kept; anything longer is malformed, not truncated. */
|
|
81
|
-
export declare const SENDER_ID_CAP = 256;
|
|
82
|
-
/**
|
|
83
|
-
* Restore-side allowlist for one persisted sender: a well-formed value comes
|
|
84
|
-
* back as a fresh object holding only kind, id and (when present) face; anything else is dropped whole (the row
|
|
85
|
-
* itself survives — a bad sender never costs the debt record).
|
|
86
|
-
* @param raw - the persisted value.
|
|
87
|
-
* @returns the sender, or undefined when absent or malformed.
|
|
88
|
-
*/
|
|
89
|
-
export declare function parseSender(raw: unknown): Sender | undefined;
|
|
90
|
-
/** #139: what an outbound row is born with besides its sender. */
|
|
91
|
-
export interface OutboundBirth {
|
|
92
|
-
readonly forwarded?: true;
|
|
93
|
-
/** The local-candidate admission (ingress local); kept only on a row whose peer is 'local'. */
|
|
94
|
-
readonly auth?: TaskAuthorization;
|
|
95
|
-
readonly peerKey?: string;
|
|
96
|
-
readonly peerHost?: string;
|
|
97
|
-
readonly deliveredTo?: string;
|
|
98
|
-
}
|
|
99
|
-
/** One tracked outbound task still in the owed book (unsettled). */
|
|
100
|
-
export interface TaskRecord {
|
|
101
|
-
readonly taskId: string;
|
|
102
|
-
readonly team: string;
|
|
103
|
-
readonly peer: string;
|
|
104
|
-
readonly startedAt: number;
|
|
105
|
-
/**
|
|
106
|
-
* Which side of the wire this row serves: 'outbound' (this node sent and
|
|
107
|
-
* awaits a receipt) or 'inbound' (this node received and owes one).
|
|
108
|
-
* Absent on rows persisted before F2' — those are outbound by birth.
|
|
109
|
-
*/
|
|
110
|
-
readonly direction?: 'inbound' | 'outbound';
|
|
111
|
-
/**
|
|
112
|
-
* F5: whether a receipt is actually owed. Absent ⇒ true (back-compat).
|
|
113
|
-
* `false` marks fire-and-forget deliveries — the caller's label could not
|
|
114
|
-
* receive a receipt at dispatch (process-form label), so no debt was born
|
|
115
|
-
* and supervision must not chase this row.
|
|
116
|
-
*/
|
|
117
|
-
readonly receiptExpected?: boolean;
|
|
118
|
-
/** The conversation the delivery steered, for follow-up routes. */
|
|
119
|
-
readonly contextId?: string;
|
|
120
|
-
/**
|
|
121
|
-
* Head of the dispatched message (first non-empty line, {@link BRIEF_CAP}).
|
|
122
|
-
* What the row is ABOUT — the fact that makes the ledger readable without
|
|
123
|
-
* replaying the conversation. Absent on rows persisted before this field.
|
|
124
|
-
*/
|
|
125
|
-
readonly brief?: string;
|
|
126
|
-
/** #114 S3: the real sender of an outbound row (absent on inbound rows and rows persisted before the field). */
|
|
127
|
-
readonly sender?: Sender;
|
|
128
|
-
/** Home host forwarded an online CLI request to a peer. */
|
|
129
|
-
readonly forwarded?: true;
|
|
130
|
-
/**
|
|
131
|
-
* #114 S2: the immutable authorization the row was born with — an inbound
|
|
132
|
-
* row's wire admission, or (#139) an outbound row's local-candidate
|
|
133
|
-
* admission (`ingress: 'local'`). Absent on remote outbound and older rows.
|
|
134
|
-
*/
|
|
135
|
-
readonly auth?: TaskAuthorization;
|
|
136
|
-
/** #139: the public key of the peer card a remote outbound row was dispatched to. */
|
|
137
|
-
readonly peerKey?: string;
|
|
138
|
-
/** #139: that card's session label (the peer host's process label). */
|
|
139
|
-
readonly peerHost?: string;
|
|
140
|
-
/** #139: the member that actually received the task (canvas / bare-zone deliveries). */
|
|
141
|
-
readonly deliveredTo?: string;
|
|
142
|
-
status: TaskStatus;
|
|
143
|
-
/** When the row was swept to dead-letter (absent while pending). */
|
|
144
|
-
deadAt?: number;
|
|
145
|
-
/**
|
|
146
|
-
* F5 layer-3: why the row aged out. 'fire-forget' — a receiptExpected:false
|
|
147
|
-
* row expiring is expected bookkeeping, not a failure; 'unconsumed' — a
|
|
148
|
-
* receipt-owed row never settled, the actionable stale shape. Absent on
|
|
149
|
-
* dead rows persisted before this field (rendered as 'unspecified').
|
|
150
|
-
*/
|
|
151
|
-
reason?: DeadReason;
|
|
152
|
-
}
|
|
153
|
-
/** One settled task kept for audit, with its receipt-correlated outcome. */
|
|
154
|
-
export interface ArchivedRecord {
|
|
155
|
-
readonly taskId: string;
|
|
156
|
-
readonly team: string;
|
|
157
|
-
readonly peer: string;
|
|
158
|
-
readonly startedAt: number;
|
|
159
|
-
readonly contextId?: string;
|
|
160
|
-
readonly resolvedAt: number;
|
|
161
|
-
readonly summary?: string;
|
|
162
|
-
/** Head of the dispatched message that this row settled (see {@link TaskRecord.brief}). */
|
|
163
|
-
readonly brief?: string;
|
|
164
|
-
/** Controlled vocabulary verdict when the settling receipt carried a v2 envelope; forced 'abandoned' for caller-abandoned rows. */
|
|
165
|
-
readonly outcome?: ReceiptOutcomeV2 | 'abandoned';
|
|
166
|
-
/** Remote-turn wall-clock cost, when the settling envelope carried it. */
|
|
167
|
-
readonly elapsedMs?: number;
|
|
168
|
-
/**
|
|
169
|
-
* When a receipt arrived for an explicitly abandoned row ({@link TaskLedger.abandon}).
|
|
170
|
-
* Orphan isolation: the abandonment outcome stays verbatim; the late answer is
|
|
171
|
-
* recorded as metadata only, never promoted to resolution.
|
|
172
|
-
*/
|
|
173
|
-
readonly lateReceiptAt?: number;
|
|
174
|
-
/**
|
|
175
|
-
* gh#31: which archive path ended the row, recorded at archive time. Absent
|
|
176
|
-
* on rows archived before the field — {@link archivedEndedBy} derives it.
|
|
177
|
-
*/
|
|
178
|
-
readonly endedBy?: ArchiveEnd;
|
|
179
|
-
/** gh#31: which side of the wire the row served, copied from the owed row (absent on rows archived before the field). */
|
|
180
|
-
readonly direction?: 'inbound' | 'outbound';
|
|
181
|
-
/**
|
|
182
|
-
* gh#68: copied from the owed row when it awaited no receipt, so a tidied
|
|
183
|
-
* fire-forget expiry is not read as a send we gave up on. Only `false` is
|
|
184
|
-
* ever stored; absent means a receipt was owed (or the row predates the field).
|
|
185
|
-
*/
|
|
186
|
-
readonly receiptExpected?: false;
|
|
187
|
-
/** #114 S3: copied from the owed row (absent on inbound rows and rows archived before the field). */
|
|
188
|
-
readonly sender?: Sender;
|
|
189
|
-
/** Home host forwarded an online CLI request to a peer. */
|
|
190
|
-
readonly forwarded?: true;
|
|
191
|
-
/** #114 S2: carried over from the live row unchanged. */
|
|
192
|
-
readonly auth?: TaskAuthorization;
|
|
193
|
-
/** #139: carried over from the live row unchanged (a late receipt is judged against them). */
|
|
194
|
-
readonly peerKey?: string;
|
|
195
|
-
readonly peerHost?: string;
|
|
196
|
-
readonly deliveredTo?: string;
|
|
197
|
-
/** #114 S4: who settled the row (receipt and echo ends), and their chain role. */
|
|
198
|
-
readonly settledBy?: SettledBy;
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* How one archived row ended (gh#31). The recorded field wins; legacy rows
|
|
202
|
-
* fall back to the summary markers this ledger writes itself. The fallback
|
|
203
|
-
* reads a receipt-settled row's summary, which is the peer's text, so it is
|
|
204
|
-
* only as exact as the legacy data allows.
|
|
205
|
-
* @param row - the archived record.
|
|
206
|
-
* @returns the archive path that ended it.
|
|
207
|
-
*/
|
|
208
|
-
export declare function archivedEndedBy(row: ArchivedRecord): ArchiveEnd;
|
|
209
|
-
/** Result of an explicit caller-side abandon. Total and never-thrown. */
|
|
210
|
-
export type AbandonOutcome = 'cleared' | 'already-terminal' | 'unknown';
|
|
211
|
-
/** Structured verdict for one {@link TaskLedger.abandon} call (consumer contract: idempotent). */
|
|
212
|
-
export interface AbandonResult {
|
|
213
|
-
readonly outcome: AbandonOutcome;
|
|
214
|
-
readonly taskId: string;
|
|
215
|
-
/** Settle time: fresh abandonment's clock tick, or the already-settled row's original resolvedAt. */
|
|
216
|
-
readonly archivedAt?: number;
|
|
217
|
-
}
|
|
218
|
-
/** Result of a cooperative {@link TaskLedger.cancel}; adds routing facts for the notify half. */
|
|
219
|
-
export interface CancelledResult extends AbandonResult {
|
|
220
|
-
/** Team the ended row addressed — the route layer reaches a live local target through it. */
|
|
221
|
-
readonly team?: string;
|
|
222
|
-
readonly contextId?: string;
|
|
223
|
-
}
|
|
224
|
-
/** Per-instance knobs; defaults come from the exported constants. */
|
|
225
|
-
export interface TaskLedgerOptions {
|
|
226
|
-
/** Stale-TTL override (tests inject short windows); defaults to {@link TASK_STALE_TTL_MS}. */
|
|
227
|
-
staleTtlMs?: number;
|
|
228
|
-
/** Archive capacity override; defaults to {@link ARCHIVE_CAP}. */
|
|
229
|
-
archiveCap?: number;
|
|
230
|
-
/**
|
|
231
|
-
* Clock injection for tests: every sweep/TTL decision reads the time from
|
|
232
|
-
* here. Defaults to Date.now. Injecting a steppable clock makes the stale
|
|
233
|
-
* transition deterministic - no real sleeps to race under load.
|
|
234
|
-
*/
|
|
235
|
-
now?: () => number;
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* What one correlated receipt settled — the payload of the
|
|
239
|
-
* `a2a/receipt-resolved` event, emitted for consumers that react to receipt
|
|
240
|
-
* arrivals (P2: native-teams settles its outstanding async submissions from
|
|
241
|
-
* this seam). `outcome` carries the v2 envelope's controlled-vocabulary
|
|
242
|
-
* verdict when the receipt rode one; `late` marks a receipt answering a
|
|
243
|
-
* dead-lettered row (revival) or an abandoned row (arrival recorded) — a
|
|
244
|
-
* healthy row's duplicate receipt refreshes the archive with NO marker.
|
|
245
|
-
*/
|
|
246
|
-
export interface ReceiptResolvedInfo {
|
|
247
|
-
readonly taskId: string;
|
|
248
|
-
/** The team the task was routed to (as the dispatcher recorded it). */
|
|
249
|
-
readonly team: string;
|
|
250
|
-
/** The peer (or `'local'`) the task was dispatched through. */
|
|
251
|
-
readonly peer: string;
|
|
252
|
-
/** v2 envelope outcome, when the receipt rode one. */
|
|
253
|
-
readonly outcome?: string;
|
|
254
|
-
/** One-line outcome summary, when the receipt carried one. */
|
|
255
|
-
readonly summary?: string;
|
|
256
|
-
/** True when the receipt answered a dead-lettered or abandoned row. */
|
|
257
|
-
readonly late?: boolean;
|
|
258
|
-
}
|
|
259
|
-
/** The persisted ledger document: the owed book plus its settled archive. */
|
|
260
|
-
export interface TaskLedgerSnapshot {
|
|
261
|
-
readonly tasks: readonly TaskRecord[];
|
|
262
|
-
readonly archived?: readonly ArchivedRecord[];
|
|
263
|
-
/** #114 S4: the bounded ring of refused / would-be-refused settlements. */
|
|
264
|
-
readonly settlementRefusals?: readonly SettlementRefusalRecord[];
|
|
265
|
-
/** #114 S4: monotonic count of every refusal noted. */
|
|
266
|
-
readonly settlementRefusalsTotal?: number;
|
|
267
|
-
/** gh#154: lifetime conservation counters (persisted; absent = pre-#154 legacy) — the module contract, shared by classifier, counters, and this schema (review P3). */
|
|
268
|
-
readonly conservation?: ConservationSnapshot;
|
|
269
|
-
}
|
|
270
|
-
/** #114 S4: who settled an archived row, and how they stood on its authorization chain. */
|
|
271
|
-
export interface SettledBy {
|
|
272
|
-
readonly caller: string;
|
|
273
|
-
readonly chain_role: ChainRole | 'off-chain';
|
|
274
|
-
}
|
|
275
|
-
/** #114 S4: one settlement the judgement refused (enforce) or would have refused (observe). */
|
|
276
|
-
export interface SettlementRefusalRecord {
|
|
277
|
-
readonly taskId: string;
|
|
278
|
-
readonly at: number;
|
|
279
|
-
readonly settler: {
|
|
280
|
-
readonly caller: string;
|
|
281
|
-
readonly proof: AuthProof;
|
|
282
|
-
readonly ingress: 'wire' | 'local';
|
|
283
|
-
};
|
|
284
|
-
readonly reason: SettlementReason;
|
|
285
|
-
/** The receipt's first line, bounded. */
|
|
286
|
-
readonly head: string;
|
|
287
|
-
/** true: the settlement was refused (enforce); false: recorded only (observe). */
|
|
288
|
-
readonly enforced: boolean;
|
|
289
|
-
/** Repeats of the same (task, settler, reason) folded into this entry, so a flood cannot push older evidence out (review of PR #140 P2-4). */
|
|
290
|
-
readonly count: number;
|
|
291
|
-
}
|
|
292
|
-
/** Bound of the refusal ring (design §4.4). */
|
|
293
|
-
export declare const SETTLEMENT_REFUSAL_CAP = 64;
|
|
294
|
-
/**
|
|
295
|
-
* #114 S4: how the ledger asks whether a settler may settle a row. `check`
|
|
296
|
-
* is the pure judgement (task-auth.ts judgeSettlement) bound to this
|
|
297
|
-
* receipt's facts; `enforce` decides whether an off-chain verdict blocks
|
|
298
|
-
* the settlement or is only recorded.
|
|
299
|
-
*/
|
|
300
|
-
export interface SettlementJudge {
|
|
301
|
-
readonly enforce: boolean;
|
|
302
|
-
readonly settler: SettlementRefusalRecord['settler'];
|
|
303
|
-
check(row: Pick<TaskRecord, 'team' | 'peer' | 'direction' | 'auth' | 'peerKey' | 'peerHost' | 'deliveredTo'>, echo: boolean): SettlementVerdict;
|
|
304
|
-
}
|
|
21
|
+
import type { TaskAuthorization } from './task-auth.ts';
|
|
22
|
+
import { type ConservationVerdict } from './ledger-conservation.ts';
|
|
23
|
+
import type { TaskRecord, ArchivedRecord, Sender, OutboundBirth, TaskLedgerOptions, ReceiptResolvedInfo, SettlementRefusalRecord, SettlementJudge, AbandonResult, CancelledResult } from './task-ledger-rows.ts';
|
|
24
|
+
export * from './task-ledger-rows.ts';
|
|
305
25
|
/**
|
|
306
26
|
* Bounded outbound-task ledger with whole-file persistence. Pure of any
|
|
307
27
|
* routing knowledge: callers {@link track} a task when a route leaves it
|
|
@@ -377,6 +97,8 @@ export declare class TaskLedger {
|
|
|
377
97
|
* @param contextId - the delivery's conversation id, kept for follow-up routes (empty omits it).
|
|
378
98
|
*/
|
|
379
99
|
trackInbound(taskId: string, team: string, from: string, contextId?: string, receiptExpected?: boolean, brief?: string, auth?: TaskAuthorization, deliveredTo?: string): void;
|
|
100
|
+
/** Record an answer only from the local member that actually received this row; never settle or change counters. */
|
|
101
|
+
markAnswered(taskId: string, target: string): boolean;
|
|
380
102
|
/**
|
|
381
103
|
* v0.5.23 (async-stall): whether a tracked task still owes its receipt.
|
|
382
104
|
* The async nudge consults this — a resolved or dead-lettered task
|
|
@@ -17,6 +17,7 @@ export type DirectoryTeamRow = {
|
|
|
17
17
|
team: string;
|
|
18
18
|
session: string;
|
|
19
19
|
name: string;
|
|
20
|
+
seatName?: string;
|
|
20
21
|
description: string;
|
|
21
22
|
local?: boolean;
|
|
22
23
|
origin?: string;
|
|
@@ -67,6 +68,7 @@ export interface InfoToolsDeps {
|
|
|
67
68
|
readonly sessionTeamOf: (agent: Agent) => string;
|
|
68
69
|
readonly nodeMetadataOf: (agent: Agent) => {
|
|
69
70
|
name: string;
|
|
71
|
+
seatName?: string;
|
|
70
72
|
description: string;
|
|
71
73
|
workspace?: string;
|
|
72
74
|
};
|
package/lib/types/types.d.ts
CHANGED
|
@@ -72,8 +72,9 @@ export interface A2aPeerCard {
|
|
|
72
72
|
export interface A2aSessionTeamInfo {
|
|
73
73
|
/** Session team name (`<team>/<agentId8>`); the route target. */
|
|
74
74
|
readonly team: string;
|
|
75
|
-
/**
|
|
75
|
+
/** Human seatName when configured, otherwise the session title at read time. */
|
|
76
76
|
readonly name: string;
|
|
77
|
+
readonly seatName?: string;
|
|
77
78
|
/** One-line excerpt of the session's newest user or assistant text. */
|
|
78
79
|
readonly description: string;
|
|
79
80
|
/** CLI deposit status, explicitly online/offline on full host cards; absent on ordinary sessions. */
|
|
@@ -83,11 +83,16 @@ host_prefix = 'dsh'
|
|
|
83
83
|
for _t in _BASE.get('canvas', {}).get('teams', []):
|
|
84
84
|
teams[_t['name']] = [m['id'] for m in _t.get('members', [])]
|
|
85
85
|
for _m in _t.get('members', []):
|
|
86
|
-
member_rows[_m['id']] = _m
|
|
86
|
+
member_rows[_m['id']] = _m # every member row, not only the first
|
|
87
87
|
_p = _m.get('team', '').split('/')[0]
|
|
88
|
-
if _p:
|
|
88
|
+
if _p and '/' not in _m['id']:
|
|
89
89
|
host_prefix = _p
|
|
90
|
-
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def valid_remote_ref(mid):
|
|
93
|
+
"""Mirror of canvas-store.validRemoteMemberRef: a '/'-qualified handle, no whitespace, no empty side."""
|
|
94
|
+
return (isinstance(mid, str) and '/' in mid and mid == mid.strip() and not any(c.isspace() for c in mid)
|
|
95
|
+
and not mid.startswith('/') and not mid.endswith('/') and '//' not in mid)
|
|
91
96
|
|
|
92
97
|
|
|
93
98
|
def valid_name(raw):
|
|
@@ -118,7 +123,9 @@ def build_state(demo: bool = False) -> bytes:
|
|
|
118
123
|
for mid in ids:
|
|
119
124
|
row = member_rows.get(mid)
|
|
120
125
|
if row is None:
|
|
121
|
-
|
|
126
|
+
# A remote ref renders as the real state face does: { id, team: the handle, remote: true }.
|
|
127
|
+
row = ({'id': mid, 'team': mid, 'remote': True} if '/' in mid
|
|
128
|
+
else {'id': mid, 'team': f'{host_prefix}/{mid[8:16] if mid.startswith("session-") else mid[:8]}', 'joined': True, 'live': True})
|
|
122
129
|
member_rows[mid] = row
|
|
123
130
|
members.append(row)
|
|
124
131
|
team_rows.append({'name': name, 'team': f'{host_prefix}/canvas/{name}', 'members': members})
|
|
@@ -208,8 +215,9 @@ class MockHost(SimpleHTTPRequestHandler):
|
|
|
208
215
|
elif action == 'add-member':
|
|
209
216
|
name = valid_name(body.get('name'))
|
|
210
217
|
mid = body.get('id')
|
|
211
|
-
|
|
212
|
-
|
|
218
|
+
# Dual member shape, as the real host: a joined local session id, or a remote team handle.
|
|
219
|
+
if name is None or not isinstance(mid, str) or not (mid in joined_ids or valid_remote_ref(mid)):
|
|
220
|
+
self._json(b'{"ok":false,"error":"name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required"}')
|
|
213
221
|
return
|
|
214
222
|
roster = teams.setdefault(name, [])
|
|
215
223
|
if mid in roster:
|
|
@@ -43,15 +43,17 @@ export function createCardElement(id: string): HTMLDivElement {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* The right-hand chip. Peer cards show the link badge (gh#20 W-A: linked green / stranger gray, then the
|
|
46
|
-
* verified tick/cross; an absent link field from an older host renders the neutral 'peer')
|
|
47
|
-
*
|
|
46
|
+
* verified tick/cross; an absent link field from an older host renders the neutral 'peer'). A remote MEMBER card
|
|
47
|
+
* (gh#38) is one session on that host, not the host: it reads 成员, colored and ticked by its host's link
|
|
48
|
+
* (it used to fall through to 'peer' and looked like a host card). Session cards show their first membership
|
|
49
|
+
* priority `P<n>`. Writes only on change.
|
|
48
50
|
*/
|
|
49
51
|
export function paintPrio(prioEl: HTMLElement, n: WorldNode, info: { link?: 'linked' | 'stranger'; verified?: boolean } | undefined): void {
|
|
50
52
|
if (n.remote === true) {
|
|
51
53
|
const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
|
|
52
54
|
if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
|
|
53
55
|
const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
|
|
54
|
-
const badge = (info?.link ?? 'peer') + mark
|
|
56
|
+
const badge = (n.id.startsWith('member-') ? '成员' : info?.link ?? 'peer') + mark
|
|
55
57
|
if (prioEl.textContent !== badge) prioEl.textContent = badge
|
|
56
58
|
} else {
|
|
57
59
|
if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Pure joined-local
|
|
1
|
+
/** Pure membership projections: joined-local handles for remote organization members, canvas team indexes, mirror-frame members. */
|
|
2
2
|
import type { RemoteTeamRow } from './federation'
|
|
3
3
|
import type { SessionLite } from './world'
|
|
4
4
|
|
|
@@ -32,3 +32,30 @@ export function remoteMemberBoardId(member: string, localIdsByHandle: ReadonlyMa
|
|
|
32
32
|
const peerZone = remoteTeams?.find(row => row.via === peerUrl)?.team.split('/')[0] ?? 'dsh'
|
|
33
33
|
return `member-${peerZone}-${member.replace(/^session-/, '').slice(0, 8)}`
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/** Each member id → the canvas teams it sits in, with its position in that team's member order. */
|
|
37
|
+
export function membershipIndex(teams: ReadonlyArray<{ name: string; members: ReadonlyArray<{ id: string }> }>): Map<string, Array<{ team: string; index: number }>> {
|
|
38
|
+
const memberships = new Map<string, Array<{ team: string; index: number }>>()
|
|
39
|
+
for (const team of teams) {
|
|
40
|
+
for (let i = 0; i < team.members.length; i++) {
|
|
41
|
+
const id = team.members[i]!.id
|
|
42
|
+
const list = memberships.get(id) ?? []
|
|
43
|
+
list.push({ team: team.name, index: i })
|
|
44
|
+
memberships.set(id, list)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return memberships
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Local joined sessions that sit in a peer's org team (a mirror frame): they are teamed even with no local team. */
|
|
51
|
+
export function mirrorMemberIds(peerOrg: ReadonlyArray<{ org: ReadonlyArray<{ members: readonly string[] }> }>, sessions: ReadonlyArray<HandleSession>, remoteTeams: ReadonlyArray<RemoteTeamRow> | undefined): Set<string> {
|
|
52
|
+
const byHandle = joinedLocalIdsByHandle(sessions, new Set((remoteTeams ?? []).map(session => session.team)))
|
|
53
|
+
const ids = new Set<string>()
|
|
54
|
+
for (const row of peerOrg) {
|
|
55
|
+
for (const team of row.org) for (const member of team.members) {
|
|
56
|
+
const id = byHandle.get(member)
|
|
57
|
+
if (id !== undefined) ids.add(id)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return ids
|
|
61
|
+
}
|
|
@@ -306,14 +306,13 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
306
306
|
const create = menuItem('组成团队…', model.selectedIds().length >= 2, () => openNameDialog(selectedByY()))
|
|
307
307
|
create.title = '先框选或 Shift 加选至少 2 个节点'
|
|
308
308
|
items.push(create)
|
|
309
|
+
const teamsOf = model.getNode(id)?.memberships.map(m => m.team) ?? []
|
|
309
310
|
if (d.teams().length > 0) {
|
|
310
311
|
items.push(menuItem('加入团队 ▸', true, () => openMenu(x, y, target, 'join')))
|
|
311
|
-
|
|
312
|
-
if (teamsOf.length > 0) {
|
|
313
|
-
items.push(menuItem('置顶路由 ▸', true, () => openMenu(x, y, target, 'promote')))
|
|
314
|
-
items.push(menuItem('离队 ▸', true, () => openMenu(x, y, target, 'leave')))
|
|
315
|
-
}
|
|
312
|
+
if (teamsOf.length > 0) items.push(menuItem('置顶路由 ▸', true, () => openMenu(x, y, target, 'promote')))
|
|
316
313
|
}
|
|
314
|
+
// A card that is only in mirror frames (a peer's team) still has a team to leave.
|
|
315
|
+
if (teamsOf.length > 0) items.push(menuItem('离队 ▸', true, () => openMenu(x, y, target, 'leave')))
|
|
317
316
|
// Network-level leave (distinct from canvas-team membership): the
|
|
318
317
|
// panel-slim migration moved the join/leave toggle here. Remote
|
|
319
318
|
// nodes are read-only entries — never a leave target.
|
|
@@ -342,6 +341,14 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
342
341
|
}))
|
|
343
342
|
}
|
|
344
343
|
}
|
|
344
|
+
// d.teams() is this host's own teams; a mirror frame's membership leaves through
|
|
345
|
+
// the owner's relay (the view adds fleetPeer), so list those memberships too.
|
|
346
|
+
if (level === 'leave') {
|
|
347
|
+
const local = new Set(d.teams().map(t => t.name))
|
|
348
|
+
for (const m of model.getNode(id)?.memberships ?? []) {
|
|
349
|
+
if (!local.has(m.team)) items.push(menuItem(titleOf(m.team), true, () => d.emit({ type: 'remove-member', team: m.team, ids: [id] })))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
345
352
|
}
|
|
346
353
|
}
|
|
347
354
|
el.append(...items)
|
|
@@ -30,7 +30,8 @@ import { buildLayoutDoc, clampDoc, type LayoutDoc, type LayoutRect } from './lay
|
|
|
30
30
|
import { FRAME_HEAD_LIFT } from './edges'
|
|
31
31
|
import { drawMemberEdges } from './planning-edges'
|
|
32
32
|
import { createFederationOverlay } from './federation-overlay'
|
|
33
|
-
import { joinedLocalIdsByHandle, remoteMemberBoardId } from './planning-member-handles'
|
|
33
|
+
import { joinedLocalIdsByHandle, membershipIndex, mirrorMemberIds, remoteMemberBoardId } from './planning-member-handles'
|
|
34
|
+
import { attachSessionDrop } from './session-drag'
|
|
34
35
|
import { displayName } from './stage-name'
|
|
35
36
|
import {
|
|
36
37
|
activityEdges, federalEdges, flyBounds, groupRemoteTeams, peerNodeId, peerHostOf, placePeers,
|
|
@@ -431,7 +432,9 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
431
432
|
if (subEl.textContent !== subText) subEl.textContent = subText
|
|
432
433
|
const tip = unavailable ? `${txt.tip} · ${UNREACHABLE_HINT}` : txt.tip
|
|
433
434
|
if (el.title !== tip) el.title = tip
|
|
434
|
-
|
|
435
|
+
// A member card's chip follows the link of the host that publishes its handle.
|
|
436
|
+
const linkHost = n.id.startsWith('member-') ? memberHostByHandle.get(n.remoteHandle ?? '') : host
|
|
437
|
+
paintPrio(el.querySelector<HTMLElement>('.prio')!, n, n.remote === true && linkHost !== undefined ? peerInfoByHost.get(linkHost) : undefined)
|
|
435
438
|
}
|
|
436
439
|
for (const [id, el] of [...nodeEls]) {
|
|
437
440
|
if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
|
|
@@ -454,6 +457,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
454
457
|
const peerScoreByText = new Map<string, number | undefined>()
|
|
455
458
|
/** gh#20 W-A: per-host link/verified info for the peer badges. */
|
|
456
459
|
const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
|
|
460
|
+
let memberHostByHandle: ReadonlyMap<string, string> = new Map()
|
|
457
461
|
|
|
458
462
|
function renderFederation(): void {
|
|
459
463
|
federation.render(clock())
|
|
@@ -622,6 +626,12 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
622
626
|
openNameDialog: menus.openNameDialog,
|
|
623
627
|
locate: locateNode,
|
|
624
628
|
})
|
|
629
|
+
// A rail 本机会话 row dropped on a frame joins that team, like a card drop (session-drag).
|
|
630
|
+
attachSessionDrop(root, {
|
|
631
|
+
signal, frames: () => frameEls,
|
|
632
|
+
frameAt: (clientX, clientY) => { const p = localXY({ clientX, clientY }); return innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y)) },
|
|
633
|
+
drop: (team, id) => model.getNode(id)?.memberships.some(m => m.team === team) === true ? notice('info', '该会话已在这个队里') : emitAction({ type: 'add-member', team, ids: [id] }),
|
|
634
|
+
})
|
|
625
635
|
|
|
626
636
|
/** Center the viewport on one canvas node (the 已组队 row's 定位). */
|
|
627
637
|
function locateNode(id: string): void {
|
|
@@ -1116,15 +1126,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1116
1126
|
// toolbar chip instead of vanishing entirely.
|
|
1117
1127
|
lastSessions = input.sessions
|
|
1118
1128
|
menus.setUnjoined(input.sessions.filter(s => s.joined !== true && !s.id.startsWith('peer-')))
|
|
1119
|
-
const memberships =
|
|
1120
|
-
for (const team of input.teams) {
|
|
1121
|
-
for (let i = 0; i < team.members.length; i++) {
|
|
1122
|
-
const id = team.members[i]!.id
|
|
1123
|
-
const list = memberships.get(id) ?? []
|
|
1124
|
-
list.push({ team: team.name, index: i })
|
|
1125
|
-
memberships.set(id, list)
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1129
|
+
const memberships = membershipIndex(input.teams)
|
|
1128
1130
|
lastCanvasMemberIds = new Set(memberships.keys())
|
|
1129
1131
|
const seen = new Set<string>()
|
|
1130
1132
|
// In-flight write guard: membership entries of teams with unsettled
|
|
@@ -1158,15 +1160,20 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1158
1160
|
syncViewSize()
|
|
1159
1161
|
clusterSeats = planTeamClusters(input.teams, seatIds, viewW > 0 ? viewW : undefined)
|
|
1160
1162
|
}
|
|
1163
|
+
// A joined session whose only team is a peer's (a mirror frame) is teamed too: without its
|
|
1164
|
+
// card the mirror frame cannot show it, and 离队 has nothing to reach (owner report 2026-11-04).
|
|
1165
|
+
const mirrorMembers = input.peerOrg !== undefined ? mirrorMemberIds(input.peerOrg, input.sessions, input.remoteTeams) : new Set<string>()
|
|
1166
|
+
// The node list reads the same fact: such a session is teamed, not 未组队.
|
|
1167
|
+
if (input.peerOrg !== undefined) lastCanvasMemberIds = new Set([...lastCanvasMemberIds, ...mirrorMembers])
|
|
1161
1168
|
for (const s of input.sessions) {
|
|
1162
1169
|
if (s.joined !== true) continue
|
|
1163
1170
|
// Teamed-only canvas (network-membership-display ruling): a joined
|
|
1164
1171
|
// session earns a canvas node only with a team — canvas membership
|
|
1165
|
-
// (optimistic-aware)
|
|
1166
|
-
// sessions ride the node list's 未组队
|
|
1167
|
-
// from `seen` are swept from the model below.
|
|
1172
|
+
// (optimistic-aware), a declared roster team, or a mirror frame's
|
|
1173
|
+
// membership. Teamless joined sessions ride the node list's 未组队
|
|
1174
|
+
// group instead; sessions absent from `seen` are swept from the model below.
|
|
1168
1175
|
const effective = resolveMemberships(s.id)
|
|
1169
|
-
if (effective.length === 0 && (s.teams === undefined || s.teams.length === 0)) continue
|
|
1176
|
+
if (effective.length === 0 && (s.teams === undefined || s.teams.length === 0) && !mirrorMembers.has(s.id)) continue
|
|
1170
1177
|
seen.add(s.id)
|
|
1171
1178
|
if (model.getNode(s.id) !== undefined) {
|
|
1172
1179
|
model.upsertNode({
|
|
@@ -1202,6 +1209,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1202
1209
|
// excluded from bounds, so the column cannot feed back into itself).
|
|
1203
1210
|
const peerSeen = new Set<string>()
|
|
1204
1211
|
lastRemoteTeams = groupRemoteTeams(input.remoteTeams ?? [])
|
|
1212
|
+
memberHostByHandle = new Map([...lastRemoteTeams].flatMap(([h, rows]) => rows.map(row => [row.team, h] as const)))
|
|
1205
1213
|
lastWork = new Map(input.sessions.map(s => [s.id, { status: s.status, queued: s.queued }]))
|
|
1206
1214
|
reachability = unreachableRefs(input)
|
|
1207
1215
|
peerScoreByText.clear()
|
|
@@ -1226,7 +1234,9 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1226
1234
|
// team row's zone/id8 handle (canvas remote refs are exact handles
|
|
1227
1235
|
// per the D1 ruling). Absent until the first remote team row lands.
|
|
1228
1236
|
const host = peerHostOf(p.url)
|
|
1229
|
-
|
|
1237
|
+
// Owner report 2026-11-04: the host's rows now lead with its bare process team (e.g. 'dsh'), which the
|
|
1238
|
+
// server reads as a local session id and refuses — take the first zone/id8 SESSION handle instead.
|
|
1239
|
+
const handle = lastRemoteTeams.get(host)?.find(row => row.team.includes('/'))?.team
|
|
1230
1240
|
model.upsertNode({
|
|
1231
1241
|
id, x: base.x, y: base.y,
|
|
1232
1242
|
label: p.url, team: '', name: p.url, peerUrl: p.url,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Human owner form only: labels never grant membership or become routing ids. */
|
|
2
|
+
export function createSeatNameEditor(sessions: readonly { id: string; name?: string; label?: string }[], names: Record<string, string>, save: (names: Record<string, string>) => Promise<{ ok: boolean; error?: string }>): HTMLElement {
|
|
3
|
+
const box = document.createElement('section')
|
|
4
|
+
const heading = document.createElement('h4'); heading.textContent = '会话席位名(仅人工设定)'; box.appendChild(heading)
|
|
5
|
+
const note = document.createElement('p'); note.className = 'wb-hint'; note.textContent = '稳定展示名,不改变会话标题、路由句柄或准入。留空并保存可清除。'; box.appendChild(note)
|
|
6
|
+
const select = document.createElement('select'); select.setAttribute('aria-label', '选择命名的本机会话')
|
|
7
|
+
for (const id of new Set([...sessions.map(row => row.id), ...Object.keys(names)])) {
|
|
8
|
+
const row = sessions.find(row => row.id === id), option = document.createElement('option')
|
|
9
|
+
option.value = id; option.textContent = (names[id] ?? row?.name ?? row?.label ?? id) + ' · ' + id; select.appendChild(option)
|
|
10
|
+
}
|
|
11
|
+
const input = document.createElement('input'); input.type = 'text'; input.maxLength = 64; input.setAttribute('aria-label', '人工席位名'); input.placeholder = '例如:开发席'
|
|
12
|
+
const button = document.createElement('button'); button.type = 'submit'; button.textContent = '保存席位名'; button.className = 'wb-member-submit'
|
|
13
|
+
const form = document.createElement('form'); form.className = 'wb-member-add'; form.append(select, input, button)
|
|
14
|
+
const status = document.createElement('p'); status.className = 'wb-hint'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite')
|
|
15
|
+
let pending = false
|
|
16
|
+
const load = () => { input.value = names[select.value] ?? ''; button.disabled = pending || select.value === '' }
|
|
17
|
+
select.addEventListener('change', load); load()
|
|
18
|
+
form.addEventListener('submit', async event => {
|
|
19
|
+
event.preventDefault(); if (pending || select.value === '') return
|
|
20
|
+
const id = select.value, value = input.value.trim(), next = { ...names }
|
|
21
|
+
if (value === '') delete next[id]; else next[id] = value
|
|
22
|
+
pending = true; button.disabled = true; select.disabled = true; input.disabled = true
|
|
23
|
+
try {
|
|
24
|
+
const result = await save(next)
|
|
25
|
+
if (result.ok) { delete names[id]; if (value !== '') names[id] = value; status.textContent = '席位名已保存;目录将随状态刷新。' }
|
|
26
|
+
else status.textContent = result.error ?? '保存失败'
|
|
27
|
+
} catch (error) { status.textContent = '保存失败:' + String(error) }
|
|
28
|
+
finally { pending = false; select.disabled = false; input.disabled = false; load() }
|
|
29
|
+
})
|
|
30
|
+
box.append(form, status); return box
|
|
31
|
+
}
|