@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.30
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 +87 -14
- package/README_zh.md +83 -7
- package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
- package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +0 -0
- package/cordis.patch.yml +21 -21
- package/lib/canvas-store.js +21 -1
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +755 -0
- package/lib/faces/canvas-notify.js +138 -0
- package/lib/faces/wire.js +669 -0
- package/lib/flood-guard.js +106 -0
- package/lib/index.js +200 -1392
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +35 -9
- package/lib/skill/references/cli.md +24 -7
- package/lib/skill/references/collaboration.md +355 -0
- package/lib/skill/references/discovery.md +5 -2
- package/lib/skill/references/pitfalls.md +2 -1
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +40 -4
- package/lib/types/canvas-store.d.ts +10 -1
- package/lib/types/faces/admin.d.ts +133 -0
- package/lib/types/faces/canvas-notify.d.ts +74 -0
- package/lib/types/faces/wire.d.ts +171 -0
- package/lib/types/flood-guard.d.ts +29 -0
- package/lib/types/index.d.ts +11 -3
- package/lib/types/recursion-guard.d.ts +56 -0
- package/lib/types/runtime.d.ts +35 -0
- package/lib/types/task-ledger.d.ts +15 -1
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/src/canvas-ops.ts +175 -175
- package/nexus-stage/src/canvas-wire.ts +156 -156
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +240 -240
- package/nexus-stage/src/layout-doc.ts +143 -143
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/main.ts +5 -1
- package/nexus-stage/src/planning-card-text.ts +73 -0
- package/nexus-stage/src/planning-edges.ts +26 -0
- package/nexus-stage/src/planning-fit.ts +45 -0
- package/nexus-stage/src/planning-legend.ts +38 -0
- package/nexus-stage/src/planning-mirror.ts +31 -0
- package/nexus-stage/src/planning-overlap.ts +159 -0
- package/nexus-stage/src/planning-relayout.ts +121 -0
- package/nexus-stage/src/planning-search.ts +168 -0
- package/nexus-stage/src/planning-types.ts +1 -0
- package/nexus-stage/src/planning-undo.ts +68 -0
- package/nexus-stage/src/planning-view.ts +120 -121
- package/nexus-stage/src/planning.css +41 -8
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/nexus-stage/src/viewport.ts +24 -1
- package/nexus-stage/src/wb-chat.ts +20 -6
- package/nexus-stage/src/wb-ledger.ts +20 -7
- package/nexus-stage/src/wb-names.ts +43 -0
- package/nexus-stage/src/workbench.css +8 -2
- package/nexus-stage/src/workbench.ts +55 -17
- package/nexus-stage/src/world.ts +13 -21
- package/package.json +216 -214
- package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
- package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
package/nexus-stage/src/seat.ts
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic fallback seating: a session id hashes to a stable polar
|
|
3
|
-
* seat, so a reload reproduces the same star map even when no layout is
|
|
4
|
-
* persisted (saved layouts still win - main.ts checks them first). This
|
|
5
|
-
* replaces three Math.random() calls whose only product was a star map
|
|
6
|
-
* that reshuffled on every reload, making screenshots and sessions
|
|
7
|
-
* incomparable. Same hash family as getHue (FNV-1a 32-bit); zero
|
|
8
|
-
* dependencies so the unit test pins stability/bounds without three.
|
|
9
|
-
* @module nexus-stage/seat
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** Radial distance bounds, mirroring the historical random-seat envelope. */
|
|
13
|
-
export const SEAT_DIST_MIN = 12
|
|
14
|
-
export const SEAT_DIST_MAX = 30
|
|
15
|
-
/** Vertical jitter bound in world units above/below the grid plane. */
|
|
16
|
-
export const SEAT_Y_SPAN = 1
|
|
17
|
-
|
|
18
|
-
/** FNV-1a 32-bit - the same determinism family as getHue. */
|
|
19
|
-
export function hashId(sid: string): number {
|
|
20
|
-
let h = 0x811c9dc5
|
|
21
|
-
for (let i = 0; i < sid.length; i++) {
|
|
22
|
-
h ^= sid.charCodeAt(i)
|
|
23
|
-
h = Math.imul(h, 0x01000193) >>> 0
|
|
24
|
-
}
|
|
25
|
-
return h >>> 0
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Deterministic world seat (x, vertical jitter, z) for one session id. */
|
|
29
|
-
export function seatFor(sid: string): { x: number; y: number; z: number } {
|
|
30
|
-
const h = hashId(sid)
|
|
31
|
-
const angle = ((h % 3600) / 3600) * Math.PI * 2
|
|
32
|
-
const dist = SEAT_DIST_MIN + (((h >>> 9) % 1800) / 1800) * (SEAT_DIST_MAX - SEAT_DIST_MIN)
|
|
33
|
-
const y = (((h >>> 18) % 2000) / 1999) * 2 * SEAT_Y_SPAN - SEAT_Y_SPAN
|
|
34
|
-
return { x: Math.cos(angle) * dist, y, z: Math.sin(angle) * dist }
|
|
35
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic fallback seating: a session id hashes to a stable polar
|
|
3
|
+
* seat, so a reload reproduces the same star map even when no layout is
|
|
4
|
+
* persisted (saved layouts still win - main.ts checks them first). This
|
|
5
|
+
* replaces three Math.random() calls whose only product was a star map
|
|
6
|
+
* that reshuffled on every reload, making screenshots and sessions
|
|
7
|
+
* incomparable. Same hash family as getHue (FNV-1a 32-bit); zero
|
|
8
|
+
* dependencies so the unit test pins stability/bounds without three.
|
|
9
|
+
* @module nexus-stage/seat
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Radial distance bounds, mirroring the historical random-seat envelope. */
|
|
13
|
+
export const SEAT_DIST_MIN = 12
|
|
14
|
+
export const SEAT_DIST_MAX = 30
|
|
15
|
+
/** Vertical jitter bound in world units above/below the grid plane. */
|
|
16
|
+
export const SEAT_Y_SPAN = 1
|
|
17
|
+
|
|
18
|
+
/** FNV-1a 32-bit - the same determinism family as getHue. */
|
|
19
|
+
export function hashId(sid: string): number {
|
|
20
|
+
let h = 0x811c9dc5
|
|
21
|
+
for (let i = 0; i < sid.length; i++) {
|
|
22
|
+
h ^= sid.charCodeAt(i)
|
|
23
|
+
h = Math.imul(h, 0x01000193) >>> 0
|
|
24
|
+
}
|
|
25
|
+
return h >>> 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Deterministic world seat (x, vertical jitter, z) for one session id. */
|
|
29
|
+
export function seatFor(sid: string): { x: number; y: number; z: number } {
|
|
30
|
+
const h = hashId(sid)
|
|
31
|
+
const angle = ((h % 3600) / 3600) * Math.PI * 2
|
|
32
|
+
const dist = SEAT_DIST_MIN + (((h >>> 9) % 1800) / 1800) * (SEAT_DIST_MAX - SEAT_DIST_MIN)
|
|
33
|
+
const y = (((h >>> 18) % 2000) / 1999) * 2 * SEAT_Y_SPAN - SEAT_Y_SPAN
|
|
34
|
+
return { x: Math.cos(angle) * dist, y, z: Math.sin(angle) * dist }
|
|
35
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Display name for one stage node: the session title when present, then the
|
|
3
|
-
* routable team label (`team/<id8>`), then the host fallback label
|
|
4
|
-
* (`dsh-host-<node>-<id8>`). Untitled cold joined sessions carry no title and
|
|
5
|
-
* without the middle step the canvas drowned in dsh-host-* noise (2026-08-30
|
|
6
|
-
* user report: the planning canvas showed a dozen unreadable label cards).
|
|
7
|
-
* @module nexus-stage/stage-name
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** One node's display title. */
|
|
11
|
-
export function displayName(node: { readonly name?: string; readonly team?: string; readonly label?: string }): string {
|
|
12
|
-
return node.name ?? node.team ?? node.label ?? ''
|
|
13
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Display name for one stage node: the session title when present, then the
|
|
3
|
+
* routable team label (`team/<id8>`), then the host fallback label
|
|
4
|
+
* (`dsh-host-<node>-<id8>`). Untitled cold joined sessions carry no title and
|
|
5
|
+
* without the middle step the canvas drowned in dsh-host-* noise (2026-08-30
|
|
6
|
+
* user report: the planning canvas showed a dozen unreadable label cards).
|
|
7
|
+
* @module nexus-stage/stage-name
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** One node's display title. */
|
|
11
|
+
export function displayName(node: { readonly name?: string; readonly team?: string; readonly label?: string }): string {
|
|
12
|
+
return node.name ?? node.team ?? node.label ?? ''
|
|
13
|
+
}
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A2A Nexus design tokens.
|
|
3
|
-
* Three-layer: primitive(C) -> semantic(S) -> component-level inline styles.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export const C = {
|
|
7
|
-
bg0: '#060a12',
|
|
8
|
-
bg1: '#0d1322',
|
|
9
|
-
ink: '#dde3f0',
|
|
10
|
-
inkMuted: '#7a86a0',
|
|
11
|
-
accent: '#8b5cf6',
|
|
12
|
-
liveGreen: '#4ade80',
|
|
13
|
-
coldGrey: '#4a5568',
|
|
14
|
-
lineFederal: '#6366f1',
|
|
15
|
-
membershipLine: '#22d3ee',
|
|
16
|
-
} as const;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Semantic layer: the scene-facing names the viewer actually consumes,
|
|
20
|
-
* each pinned to its primitive above. This is the `S` main.ts reached for
|
|
21
|
-
* as `T.S.*` — the layer was documented but never exported, so every seat
|
|
22
|
-
* attempt threw and cycle()'s empty catch buried it: an eternally empty
|
|
23
|
-
* stage behind a healthy-looking render loop.
|
|
24
|
-
*/
|
|
25
|
-
export const S = {
|
|
26
|
-
/** Mesh color + emissive for live session nodes (primitive liveGreen). */
|
|
27
|
-
nodeLive: 0x4ade80,
|
|
28
|
-
/** Mesh color + emissive for cold/joined-offline nodes (primitive coldGrey). */
|
|
29
|
-
nodeCold: 0x4a5568,
|
|
30
|
-
} as const;
|
|
31
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A2A Nexus design tokens.
|
|
3
|
+
* Three-layer: primitive(C) -> semantic(S) -> component-level inline styles.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const C = {
|
|
7
|
+
bg0: '#060a12',
|
|
8
|
+
bg1: '#0d1322',
|
|
9
|
+
ink: '#dde3f0',
|
|
10
|
+
inkMuted: '#7a86a0',
|
|
11
|
+
accent: '#8b5cf6',
|
|
12
|
+
liveGreen: '#4ade80',
|
|
13
|
+
coldGrey: '#4a5568',
|
|
14
|
+
lineFederal: '#6366f1',
|
|
15
|
+
membershipLine: '#22d3ee',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Semantic layer: the scene-facing names the viewer actually consumes,
|
|
20
|
+
* each pinned to its primitive above. This is the `S` main.ts reached for
|
|
21
|
+
* as `T.S.*` — the layer was documented but never exported, so every seat
|
|
22
|
+
* attempt threw and cycle()'s empty catch buried it: an eternally empty
|
|
23
|
+
* stage behind a healthy-looking render loop.
|
|
24
|
+
*/
|
|
25
|
+
export const S = {
|
|
26
|
+
/** Mesh color + emissive for live session nodes (primitive liveGreen). */
|
|
27
|
+
nodeLive: 0x4ade80,
|
|
28
|
+
/** Mesh color + emissive for cold/joined-offline nodes (primitive coldGrey). */
|
|
29
|
+
nodeCold: 0x4a5568,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
32
|
export const FRAME_HUES = [0x8b5cf6, 0x3b82f6, 0x14b8a6, 0xf59e0b, 0xec4899, 0x84cc16];
|
|
@@ -60,16 +60,39 @@ export function zoomAt(vp: LayoutViewport, factor: number, ax: number, ay: numbe
|
|
|
60
60
|
/** Content bounds in world space (null = nothing to fit). */
|
|
61
61
|
export interface WorldBounds { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
|
|
62
62
|
|
|
63
|
+
/** A fit never shrinks past a readable floor: below {@link NARROW_FIT_WIDTH}
|
|
64
|
+
* (the board's phone breakpoint) {@link NARROW_FIT_FLOOR}, at any wider
|
|
65
|
+
* viewport {@link WIDE_FIT_FLOOR}. Fitting a whole board drew 13px card
|
|
66
|
+
* names at ~7px on a phone, and a real 66-card board at 30% on a desktop
|
|
67
|
+
* (review 2026-10-27, real-host check). Past the floor the fit anchors the
|
|
68
|
+
* content's top-left corner instead of its centre; the rest is a pan away. */
|
|
69
|
+
export const NARROW_FIT_WIDTH = 680
|
|
70
|
+
export const NARROW_FIT_FLOOR = 0.8
|
|
71
|
+
export const WIDE_FIT_FLOOR = 0.6
|
|
72
|
+
const FIT_MARGIN = 16
|
|
73
|
+
|
|
63
74
|
/**
|
|
64
75
|
* Fit the content bounds into a viewW x viewH viewport at 90% fill,
|
|
65
76
|
* clamped to the persisted scale envelope; empty bounds reset to identity.
|
|
77
|
+
* The scale is floored (see {@link WIDE_FIT_FLOOR}); an explicit maxScale
|
|
78
|
+
* below the floor still wins.
|
|
66
79
|
*/
|
|
67
80
|
export function fitView(bounds: WorldBounds | null, viewW: number, viewH: number, maxScale = Number.POSITIVE_INFINITY): LayoutViewport {
|
|
68
81
|
if (bounds === null || viewW <= 0 || viewH <= 0) return IDENTITY_VIEWPORT
|
|
69
82
|
const bw = bounds.maxX - bounds.minX
|
|
70
83
|
const bh = bounds.maxY - bounds.minY
|
|
71
84
|
if (!(bw > 0) || !(bh > 0)) return IDENTITY_VIEWPORT
|
|
72
|
-
|
|
85
|
+
// The floor is about content that does not FIT at a readable scale, so it
|
|
86
|
+
// is judged on the natural fit — a caller's maxScale cap below the floor
|
|
87
|
+
// must not turn a board that fits into a top-left-anchored one (PR #79 review).
|
|
88
|
+
const natural = 0.9 * Math.min(viewW / bw, viewH / bh)
|
|
89
|
+
const fitted = Math.min(maxScale, natural)
|
|
90
|
+
const floor = viewW < NARROW_FIT_WIDTH ? NARROW_FIT_FLOOR : WIDE_FIT_FLOOR
|
|
91
|
+
if (natural < floor) {
|
|
92
|
+
const scale = clampScale(Math.min(maxScale, floor))
|
|
93
|
+
return { x: bounds.minX - FIT_MARGIN / scale, y: bounds.minY - FIT_MARGIN / scale, scale }
|
|
94
|
+
}
|
|
95
|
+
const scale = clampScale(fitted)
|
|
73
96
|
return {
|
|
74
97
|
x: (bounds.minX + bounds.maxX) / 2 - viewW / (2 * scale),
|
|
75
98
|
y: (bounds.minY + bounds.maxY) / 2 - viewH / (2 * scale),
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* @module nexus-stage/wb-chat
|
|
20
20
|
*/
|
|
21
21
|
import type { ArchivedRow, DeadRow, PendingRow, RemoteRow, StateBody } from './workbench'
|
|
22
|
-
import { VERDICTS } from './wb-ledger'
|
|
22
|
+
import { overHour, VERDICTS } from './wb-ledger'
|
|
23
23
|
import { displayName } from './stage-name'
|
|
24
24
|
|
|
25
25
|
/** Chat target: a node picked on the board or in the directory. Session and
|
|
@@ -55,8 +55,6 @@ export interface ChatDrawer {
|
|
|
55
55
|
|
|
56
56
|
/** The composer's hard limit (dispatch route contract). */
|
|
57
57
|
const MAX_CHARS = 4000
|
|
58
|
-
/** A pending receipt older than this is 陈旧 (same definition as the ledger). */
|
|
59
|
-
const STALE_MS = 60 * 60 * 1000
|
|
60
58
|
/** Within this many px of the bottom, a repaint keeps the thread pinned there. */
|
|
61
59
|
const STICK_PX = 32
|
|
62
60
|
|
|
@@ -415,9 +413,14 @@ export function createChatDrawer(deps: ChatDrawerDeps): ChatDrawer {
|
|
|
415
413
|
function pendingStatus(row: PendingRow, now: number): SentRow['status'] {
|
|
416
414
|
const inbound = row.direction === 'inbound'
|
|
417
415
|
if (row.receiptExpected === false) return { text: inbound ? '来信 · 无需回执' : '已送达 · 无回执', tone: 'muted', hint: '免回执消息:不等回执,24h 后自动归档' }
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
416
|
+
// One hour boundary with the 台账 tab (the host's settleable verdict,
|
|
417
|
+
// browser clock only on older hosts). Past it, OUR send is 陈旧 (can be
|
|
418
|
+
// nudged); a peer's delivery is a receipt WE owe — never 陈旧 (#30 P3).
|
|
419
|
+
const late = overHour(row, now)
|
|
420
|
+
const text = inbound ? (late ? '待本机回执 · 超 1 小时' : '待本机回执') : late ? '陈旧' : '待回执'
|
|
421
|
+
// The row's own status only when it says more than 'pending'.
|
|
422
|
+
const extra = row.status !== 'pending' ? ` · ${row.status}` : ''
|
|
423
|
+
return { text: `${text}${extra}`, tone: late ? 'warn' : 'wait', hint: `${inbound ? '对方等待本机回执;' : ''}${inbound ? '收到' : '派发'}于 ${ago(row.startedAt, now)}` }
|
|
421
424
|
}
|
|
422
425
|
|
|
423
426
|
/** A send that left the ledger, by how it ended (gh#31). `endedBy` says
|
|
@@ -427,6 +430,17 @@ export function createChatDrawer(deps: ChatDrawerDeps): ChatDrawer {
|
|
|
427
430
|
* ledger's own markers, which are not shown. */
|
|
428
431
|
function settledStatus(row: ArchivedRow, now: number): SentRow['status'] {
|
|
429
432
|
const when = `结算于 ${ago(row.resolvedAt, now)}`
|
|
433
|
+
// gh#68: a fire-forget send this host moved out of the ledger (移出 on
|
|
434
|
+
// the 已过期(免回执) card, an abandon) is bookkeeping, not a failure — it
|
|
435
|
+
// never waited on a receipt, so 已放弃 would contradict the button that
|
|
436
|
+
// did it. A cancel stays 已撤回: it is a deliberate recall that also
|
|
437
|
+
// tells the peer to stop (API / tool path; the workbench offers none).
|
|
438
|
+
// A receipt that arrives after the move keeps its 回执迟到 flag.
|
|
439
|
+
if (row.receiptExpected === false && row.endedBy === 'abandoned') {
|
|
440
|
+
return row.late === true
|
|
441
|
+
? { text: '已移出(免回执) · 回执迟到', tone: 'warn', hint: `${when};免回执消息本机移出后对方回执才到,结局不改写` }
|
|
442
|
+
: { text: '已移出(免回执)', tone: 'muted', hint: `${when};免回执消息,本机移出台账,不是失败` }
|
|
443
|
+
}
|
|
430
444
|
if (row.endedBy === 'abandoned' || row.endedBy === 'cancelled') {
|
|
431
445
|
const act = row.endedBy === 'cancelled' ? '撤回' : '放弃'
|
|
432
446
|
// The peer answered after we ended it: the verdict stays ours (late
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import type { DeadRow, PendingRow, StateBody } from './workbench'
|
|
27
27
|
import type { RailTone } from './wb-rail'
|
|
28
|
+
import { nameInto } from './wb-names'
|
|
28
29
|
|
|
29
30
|
export interface LedgerContext {
|
|
30
31
|
readonly state: StateBody
|
|
@@ -54,7 +55,7 @@ function senderOf(row: { peer?: string }): string | undefined {
|
|
|
54
55
|
* settleable verdict (receiptExpected && age < 1h, computed host-side), else
|
|
55
56
|
* by ours — a workbench opened from another machine with a skewed clock then
|
|
56
57
|
* still draws the boundary where the server does (at most one poll late). */
|
|
57
|
-
function overHour(r: PendingRow, now: number): boolean {
|
|
58
|
+
export function overHour(r: PendingRow, now: number): boolean {
|
|
58
59
|
return r.settleable !== undefined ? r.settleable === false : now - r.startedAt >= HOUR
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -155,7 +156,10 @@ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: st
|
|
|
155
156
|
const staleCount = facts.stale.length
|
|
156
157
|
|
|
157
158
|
const tiles = el('div', 'wb-led-tiles')
|
|
158
|
-
|
|
159
|
+
// 待回执 counts only rows a receipt is owed on — the same rows as the
|
|
160
|
+
// 陈旧 / 待本机回执 / 等待回执 groups below; receiptless rows are their own
|
|
161
|
+
// 免回执 group and owe nothing.
|
|
162
|
+
tile(tiles, '待回执', expected.length, `要回执、仍未结算的任务(含陈旧与本机欠对方的入站行)${receiptless.length > 0 ? `;另有免回执 ${receiptless.length} 条不计` : ''}`)
|
|
159
163
|
tile(tiles, '陈旧', staleCount, '我方派发、要回执、已等待 ≥1h 仍未回执(可催办);本机欠对方的入站行在「待本机回执」组另计,免回执行不算陈旧', staleCount > 0 ? 'warn' : undefined)
|
|
160
164
|
tile(tiles, '死信', facts.deadLetters.length, '超过 24h TTL 仍未结算(unconsumed);需清账(abandon)才能移出。免回执到期另列,不算死信', facts.deadLetters.length > 0 ? 'danger' : undefined)
|
|
161
165
|
tile(tiles, '已归档', archived, '已离开台账并留档的任务(结算、拒绝或放弃),用于审计')
|
|
@@ -172,8 +176,13 @@ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: st
|
|
|
172
176
|
head.appendChild(brief)
|
|
173
177
|
node.appendChild(head)
|
|
174
178
|
const meta = el('div', 'wb-led-meta')
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
// Names, not bare ids (wb-names; the id rides each span's tooltip). An
|
|
180
|
+
// inbound row's sender is the peer handle; an unknown one reads 对端.
|
|
181
|
+
const who = meta.appendChild(el('span', 'wb-led-team'))
|
|
182
|
+
if (!c.inbound) nameInto(who, state, c.team, '发往 ')
|
|
183
|
+
else if (c.sender !== undefined) nameInto(who, state, c.sender, '来自 ')
|
|
184
|
+
else who.textContent = '来自 对端'
|
|
185
|
+
if (c.inbound) nameInto(meta.appendChild(el('span', 'wb-led-to')), state, c.team, '至 ')
|
|
177
186
|
meta.appendChild(el('span')).textContent = c.atText
|
|
178
187
|
meta.appendChild(el('span', 'wb-led-task')).textContent = c.taskId
|
|
179
188
|
for (const chip of c.chips) meta.appendChild(el('span', 'wb-led-chip')).textContent = chip
|
|
@@ -181,10 +190,13 @@ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: st
|
|
|
181
190
|
node.title = `${c.taskId}\n${c.team}`
|
|
182
191
|
if (c.dead !== undefined) {
|
|
183
192
|
const row = c.dead
|
|
184
|
-
|
|
193
|
+
// A fire-forget expiry is normal bookkeeping, not a failure: moving it
|
|
194
|
+
// out is an optional tidy, so it gets a quiet control, not 清账.
|
|
195
|
+
const expiry = row.reason === 'fire-forget'
|
|
196
|
+
const btn = el('button', `wb-btn wb-led-abandon${expiry ? ' is-quiet' : ''}`)
|
|
185
197
|
btn.type = 'button'
|
|
186
|
-
btn.textContent = '清账'
|
|
187
|
-
btn.title = '从台账移出这条死信(abandon)'
|
|
198
|
+
btn.textContent = expiry ? '移出' : '清账'
|
|
199
|
+
btn.title = expiry ? '可选:把这条已过期的免回执记录移出台账(不是失败,不处理也无妨)' : '从台账移出这条死信(abandon)'
|
|
188
200
|
btn.addEventListener('click', ev => {
|
|
189
201
|
ev.stopPropagation()
|
|
190
202
|
btn.disabled = true
|
|
@@ -209,6 +221,7 @@ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: st
|
|
|
209
221
|
const section = (label: string, rows: number, tone: string, hint: string): HTMLElement | null => {
|
|
210
222
|
if (rows === 0) return null
|
|
211
223
|
const g = el('div', `wb-led-group is-${tone}`)
|
|
224
|
+
g.dataset.group = label
|
|
212
225
|
const head = el('div', 'wb-led-group-head')
|
|
213
226
|
head.appendChild(el('span')).textContent = `${label} · ${rows}`
|
|
214
227
|
head.appendChild(el('span', 'wb-led-group-hint')).textContent = hint
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readable names for routing team ids, shared by the activity and plan
|
|
3
|
+
* panes and the 台账 tab (review 2026-10-27: rows read bare `dsh/1111…` ids;
|
|
4
|
+
* the ledger kept doing so after #72 fixed the other two panes).
|
|
5
|
+
* Names are attacker-shaped data: textContent only.
|
|
6
|
+
* @module nexus-stage/wb-names
|
|
7
|
+
*/
|
|
8
|
+
import type { StateBody } from './workbench'
|
|
9
|
+
import { displayName } from './stage-name'
|
|
10
|
+
|
|
11
|
+
/** The name behind a team id — a session, a remote node or a canvas team —
|
|
12
|
+
* or undefined when none matches. An empty name counts as no match:
|
|
13
|
+
* displayName's `??` keeps '', and a peer can publish one. `remote` marks a
|
|
14
|
+
* name the PEER chose — it can collide with a local session's, so callers
|
|
15
|
+
* must show where it is from. */
|
|
16
|
+
export function teamName(state: StateBody, team: string): { name: string; remote: boolean } | undefined {
|
|
17
|
+
const named = (name: string | undefined, remote: boolean) => name !== undefined && name.trim() !== '' ? { name, remote } : undefined
|
|
18
|
+
const s = (state.sessions ?? []).find(r => r.team === team)
|
|
19
|
+
if (s !== undefined) return named(displayName(s), false)
|
|
20
|
+
const r = (state.remote ?? []).find(x => x.team === team)
|
|
21
|
+
if (r !== undefined) return named(displayName(r), true)
|
|
22
|
+
return named((state.canvas?.teams ?? []).find(t => t.team === team)?.name, false)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A name for plain text: a peer-published one says it is remote. */
|
|
26
|
+
export function teamLabel(state: StateBody, team: string): string {
|
|
27
|
+
const n = teamName(state, team)
|
|
28
|
+
return n === undefined ? team : n.remote ? `${n.name}(远端)` : n.name
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fill `target` with `prefix` + the name (the id rides the tooltip; an
|
|
32
|
+
* unknown team stays its id); a peer-published name gets a visible 远端 chip. */
|
|
33
|
+
export function nameInto(target: HTMLElement, state: StateBody, team: string, prefix = ''): void {
|
|
34
|
+
const n = teamName(state, team)
|
|
35
|
+
target.textContent = `${prefix}${n?.name ?? team}`
|
|
36
|
+
target.title = team
|
|
37
|
+
if (n?.remote === true) {
|
|
38
|
+
const chip = target.appendChild(document.createElement('span'))
|
|
39
|
+
chip.className = 'wb-row-remote'
|
|
40
|
+
chip.textContent = '远端'
|
|
41
|
+
chip.title = '名字由对端自行发布'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -31,13 +31,17 @@ body{background:var(--n-bg);color:var(--n-ink);font:13px/1.45 system-ui,"Segoe U
|
|
|
31
31
|
.wb-header{display:flex;align-items:center;gap:12px;padding:8px 14px;border-bottom:1px solid var(--n-border);background:var(--n-panel)}
|
|
32
32
|
.wb-title{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px;white-space:nowrap}
|
|
33
33
|
.wb-badge{font-size:11px;font-weight:400;color:var(--n-ink-muted);border:1px solid var(--n-border);border-radius:99px;padding:1px 8px}
|
|
34
|
+
.wb-badge[hidden]{display:none}
|
|
35
|
+
.wb-row-remote{margin-left:6px;padding:0 6px;border:1px solid currentColor;border-radius:99px;font-size:10.5px;color:var(--n-ink-muted);vertical-align:1px}
|
|
34
36
|
.wb-header-stats{display:flex;gap:16px;font-size:12px;color:var(--n-ink-muted);flex-wrap:wrap;min-width:0}
|
|
35
37
|
.wb-header-stats .wb-kv{display:flex;gap:6px}
|
|
36
38
|
.wb-header-stats .wb-kv-k{color:var(--n-ink-muted)}
|
|
37
39
|
.wb-header-stats .wb-kv-v{color:var(--n-ink)}
|
|
38
40
|
/* Header verdict: exceptions only (results-first). Quiet when healthy. */
|
|
39
41
|
.wb-header-ok{color:var(--n-ink-muted)}
|
|
40
|
-
.wb-alert{border:1px solid currentColor;border-radius:99px;padding:0 9px;line-height:20px;white-space:nowrap;cursor:
|
|
42
|
+
.wb-alert{border:1px solid currentColor;border-radius:99px;padding:0 9px;line-height:20px;white-space:nowrap;cursor:pointer;background:transparent;font:inherit}
|
|
43
|
+
.wb-alert:hover{background:color-mix(in srgb,currentColor 12%,transparent)}
|
|
44
|
+
.wb-alert:focus-visible{outline:2px solid currentColor;outline-offset:2px}
|
|
41
45
|
.wb-alert-danger{color:var(--n-danger)}
|
|
42
46
|
.wb-alert-warn{color:var(--n-act)}
|
|
43
47
|
.wb-forensics{font-family:ui-monospace,Consolas,monospace;font-size:11px}
|
|
@@ -69,7 +73,7 @@ body{background:var(--n-bg);color:var(--n-ink);font:13px/1.45 system-ui,"Segoe U
|
|
|
69
73
|
.wb-search:focus{outline:1px solid var(--n-accent)}
|
|
70
74
|
.wb-filters{display:flex;flex-wrap:wrap;gap:4px}
|
|
71
75
|
.wb-filter{background:none;border:1px solid var(--n-border);color:var(--n-ink-muted);
|
|
72
|
-
border-radius:99px;padding:
|
|
76
|
+
border-radius:99px;padding:0 10px;min-height:24px;font:inherit;font-size:11.5px;cursor:pointer}
|
|
73
77
|
.wb-filter:hover{border-color:var(--n-accent);color:var(--n-ink)}
|
|
74
78
|
.wb-filter.is-active{border-color:var(--n-accent);color:var(--n-ink);
|
|
75
79
|
background:color-mix(in srgb,var(--n-accent) 16%,transparent)}
|
|
@@ -365,6 +369,8 @@ body{background:var(--n-bg);color:var(--n-ink);font:13px/1.45 system-ui,"Segoe U
|
|
|
365
369
|
.wb-led-meta > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}
|
|
366
370
|
.wb-led-chip{border:1px solid var(--n-border);border-radius:99px;padding:0 6px;font-family:inherit}
|
|
367
371
|
.wb-led-abandon{position:absolute;top:6px;right:6px;margin-left:0;padding:1px 9px;font-size:11.5px}
|
|
372
|
+
.wb-led-abandon.is-quiet{border-color:transparent;background:transparent;color:var(--n-ink-muted)}
|
|
373
|
+
.wb-led-abandon.is-quiet:hover{border-color:var(--n-border);color:var(--n-ink)}
|
|
368
374
|
.wb-led-card:has(.wb-led-abandon) .wb-led-card-head{padding-right:52px}
|
|
369
375
|
.wb-led-empty{padding:24px 8px;text-align:center;font-size:12px;color:var(--n-ink-muted)}
|
|
370
376
|
.wb-led-forensics{font-size:11px;color:var(--n-ink-muted);border-top:1px dashed var(--n-border);padding-top:6px;margin-top:4px}
|
|
@@ -22,6 +22,7 @@ import { createChatDrawer, type ChatTarget } from './wb-chat'
|
|
|
22
22
|
import { createRailTabs } from './wb-rail'
|
|
23
23
|
import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
|
|
24
24
|
import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
|
|
25
|
+
import { nameInto, teamLabel } from './wb-names'
|
|
25
26
|
|
|
26
27
|
/** One pending ledger row from /state `tasks`. */
|
|
27
28
|
export interface PendingRow {
|
|
@@ -72,6 +73,9 @@ export interface ArchivedRow {
|
|
|
72
73
|
readonly endedBy?: 'receipt' | 'echo' | 'abandoned' | 'cancelled'
|
|
73
74
|
/** A receipt arrived after the caller had already ended the row. */
|
|
74
75
|
readonly late?: boolean
|
|
76
|
+
/** gh#68: present (false) only when the row never expected a receipt — a
|
|
77
|
+
* fire-forget send; absent on receipt-expected rows and older hosts. */
|
|
78
|
+
readonly receiptExpected?: false
|
|
75
79
|
readonly direction?: 'inbound' | 'outbound'
|
|
76
80
|
readonly brief?: string
|
|
77
81
|
}
|
|
@@ -643,6 +647,11 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
643
647
|
}
|
|
644
648
|
|
|
645
649
|
// ── Activity pane ──
|
|
650
|
+
/** A team cell: the name when known (wb-names), else the id. */
|
|
651
|
+
function teamCell(line: HTMLElement, state: StateBody, team: string): void {
|
|
652
|
+
nameInto(line.appendChild(el('span', 'wb-row-team')), state, team)
|
|
653
|
+
}
|
|
654
|
+
|
|
646
655
|
function renderActivity(): void {
|
|
647
656
|
const pane = clear(activityPane)
|
|
648
657
|
if (last === null) return
|
|
@@ -659,7 +668,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
659
668
|
for (const row of flights) {
|
|
660
669
|
const line = el('div', 'wb-row')
|
|
661
670
|
line.appendChild(el('span', 'wb-row-dir')).textContent = '→'
|
|
662
|
-
line
|
|
671
|
+
teamCell(line, last, row.team)
|
|
663
672
|
line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.startedAt, now)}`
|
|
664
673
|
pane.appendChild(line)
|
|
665
674
|
}
|
|
@@ -678,7 +687,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
678
687
|
dir.textContent = row.dir === 'out' ? '→' : '←'
|
|
679
688
|
if (row.ok !== true) dir.classList.add('is-fail')
|
|
680
689
|
line.appendChild(dir)
|
|
681
|
-
line
|
|
690
|
+
teamCell(line, last, row.team)
|
|
682
691
|
line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.ts, now)}`
|
|
683
692
|
line.appendChild(el('span', 'wb-row-badges')).textContent = row.ok === true ? '' : '失败'
|
|
684
693
|
pane.appendChild(line)
|
|
@@ -704,18 +713,24 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
704
713
|
const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
|
|
705
714
|
actions.push({
|
|
706
715
|
severity: 'normal',
|
|
707
|
-
text: `催办 ${row.taskId} → ${row.team}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
|
|
716
|
+
text: `催办 ${row.taskId} → ${teamLabel(state, row.team)}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
|
|
708
717
|
brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
|
|
709
718
|
})
|
|
710
719
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
720
|
+
// Cold sessions are one action, not one per session: they share the same
|
|
721
|
+
// remedy (open them in the GUI), and a list of identical lines buried the
|
|
722
|
+
// actions that differ.
|
|
723
|
+
const cold = (state.sessions ?? []).filter(row => row.live !== true).map(row => displayName(row))
|
|
724
|
+
if (cold.length > 0) {
|
|
725
|
+
const names = cold.slice(0, 3).join('、') + (cold.length > 3 ? ` 等 ${cold.length} 个` : '')
|
|
726
|
+
actions.push({ severity: 'normal', text: `冷会话 ${cold.length} 个(${names}):在 GUI 打开即可唤醒加入面` })
|
|
715
727
|
}
|
|
716
728
|
for (const row of state.reconcile?.rows ?? []) {
|
|
717
729
|
if (row.needsRepair === true) {
|
|
718
|
-
|
|
730
|
+
// Name the session; the id stays for the operator who greps logs.
|
|
731
|
+
const s = (state.sessions ?? []).find(x => x.id === row.id)
|
|
732
|
+
const who = s !== undefined ? `${displayName(s)}(${row.id})` : row.id
|
|
733
|
+
actions.push({ severity: 'high', text: `reconcile 需修复:${who} ${row.error}` })
|
|
719
734
|
}
|
|
720
735
|
}
|
|
721
736
|
const conflicts = state.idempotency?.conflicts ?? 0
|
|
@@ -1122,35 +1137,58 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
|
1122
1137
|
// forensic counters that used to sit here (idempotency claims/replays,
|
|
1123
1138
|
// reconcile state) read on the ledger's 取证 line instead.
|
|
1124
1139
|
function renderHeader(): void {
|
|
1125
|
-
|
|
1140
|
+
// An unknown version says nothing: hide the badge rather than print 'v?'.
|
|
1141
|
+
versionBadge.textContent = last?.version !== undefined ? `v${last.version}` : ''
|
|
1142
|
+
versionBadge.hidden = last?.version === undefined
|
|
1126
1143
|
const box = clear(headerStats)
|
|
1127
1144
|
if (last === null) return
|
|
1128
|
-
|
|
1145
|
+
// Each pill is a way in: it opens the tab (and ledger group) that holds
|
|
1146
|
+
// the rows behind its count.
|
|
1147
|
+
const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string; tab: 'ledger' | 'plan' | 'activity'; group?: string }> = []
|
|
1129
1148
|
// Same definitions as the 台账 tab (wb-ledger ledgerFacts): fire-forget
|
|
1130
1149
|
// expiries are not dead letters, receiptless rows are never stale.
|
|
1131
1150
|
const facts = ledgerFacts(last, deps.now())
|
|
1132
1151
|
const dead = facts.deadLetters.length
|
|
1133
|
-
if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账' })
|
|
1152
|
+
if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账', tab: 'ledger', group: '死信' })
|
|
1134
1153
|
const stale = facts.stale.length
|
|
1135
|
-
if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)' })
|
|
1154
|
+
if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)', tab: 'ledger', group: '陈旧' })
|
|
1136
1155
|
const owed = facts.owedOverdue.length
|
|
1137
|
-
if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时' })
|
|
1156
|
+
if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时', tab: 'ledger', group: '待本机回执' })
|
|
1138
1157
|
const conflicts = last.idempotency?.conflicts ?? 0
|
|
1139
|
-
if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查' })
|
|
1158
|
+
if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查', tab: 'ledger', group: '取证' })
|
|
1140
1159
|
const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
|
|
1141
|
-
if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划' })
|
|
1160
|
+
if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划', tab: 'plan' })
|
|
1142
1161
|
if (alerts.length === 0) {
|
|
1143
1162
|
box.appendChild(el('span', 'wb-header-ok')).textContent = '● 无异常'
|
|
1144
1163
|
return
|
|
1145
1164
|
}
|
|
1146
1165
|
for (const a of alerts) {
|
|
1147
|
-
const pill = el('
|
|
1166
|
+
const pill = el('button', `wb-alert wb-alert-${a.tone}`)
|
|
1167
|
+
pill.type = 'button'
|
|
1148
1168
|
pill.textContent = a.text
|
|
1149
|
-
pill.title = a.title
|
|
1169
|
+
pill.title = `${a.title}(点击查看)`
|
|
1170
|
+
pill.addEventListener('click', () => openAlert(a.tab, a.group))
|
|
1150
1171
|
box.appendChild(pill)
|
|
1151
1172
|
}
|
|
1152
1173
|
}
|
|
1153
1174
|
|
|
1175
|
+
/** Open the pane behind a header pill. With the board up the panes are
|
|
1176
|
+
* rail tabs (on a narrow screen the rail slides in first); without it they
|
|
1177
|
+
* sit on the stage. A ledger group (or the 取证 fold) scrolls into view. */
|
|
1178
|
+
function openAlert(tab: 'ledger' | 'plan' | 'activity', group?: string): void {
|
|
1179
|
+
const pane = dataPanes.find(([k]) => k === tab)![1]
|
|
1180
|
+
if (mode === 'canvas') {
|
|
1181
|
+
root.classList.add('is-rail-open')
|
|
1182
|
+
railTabs.select(tab, { focus: false })
|
|
1183
|
+
}
|
|
1184
|
+
const target = group === undefined ? pane
|
|
1185
|
+
: group === '取证' ? pane.querySelector<HTMLDetailsElement>('details.wb-led-forensics')
|
|
1186
|
+
: Array.from(pane.querySelectorAll<HTMLElement>('.wb-led-group')).find(g => g.dataset.group === group) ?? null
|
|
1187
|
+
if (target === null) return
|
|
1188
|
+
if (target instanceof HTMLDetailsElement) target.open = true
|
|
1189
|
+
target.scrollIntoView?.({ block: 'start', behavior: 'smooth' })
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1154
1192
|
function update(state: StateBody | null): void {
|
|
1155
1193
|
last = state
|
|
1156
1194
|
// 0.6.12: refresh the readable-log list opportunistically (single flight;
|