@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.42

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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  4. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  5. package/assets/stageDist/index.html +12 -12
  6. package/lib/client.js +64 -64
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +57 -9
  9. package/lib/origin-claim.js +4 -0
  10. package/lib/skill/SKILL.md +117 -117
  11. package/lib/skill/references/collaboration.md +428 -428
  12. package/lib/skill/references/discovery.md +78 -78
  13. package/lib/skill/references/intake.md +107 -107
  14. package/lib/skill/references/membership.md +73 -73
  15. package/lib/skill/references/pitfalls.md +51 -51
  16. package/lib/skill/references/router.md +96 -96
  17. package/lib/skill/references/routing.md +65 -65
  18. package/lib/types/origin-claim.d.ts +2 -0
  19. package/nexus-stage/package.json +21 -21
  20. package/nexus-stage/scripts/mockhost.py +261 -261
  21. package/nexus-stage/src/canvas-ops.ts +191 -191
  22. package/nexus-stage/src/canvas-wire.ts +164 -164
  23. package/nexus-stage/src/cli-home-picker.ts +42 -42
  24. package/nexus-stage/src/fault.ts +56 -56
  25. package/nexus-stage/src/federation-overlay.ts +103 -103
  26. package/nexus-stage/src/federation.ts +241 -241
  27. package/nexus-stage/src/layout-doc.ts +145 -145
  28. package/nexus-stage/src/main.ts +513 -513
  29. package/nexus-stage/src/planning-card-text.ts +73 -73
  30. package/nexus-stage/src/planning-card.ts +63 -63
  31. package/nexus-stage/src/planning-edges.ts +26 -26
  32. package/nexus-stage/src/planning-fit.ts +45 -45
  33. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  34. package/nexus-stage/src/planning-legend.ts +39 -39
  35. package/nexus-stage/src/planning-member-handles.ts +61 -61
  36. package/nexus-stage/src/planning-menus.ts +422 -422
  37. package/nexus-stage/src/planning-mirror.ts +75 -75
  38. package/nexus-stage/src/planning-nodelist.ts +294 -294
  39. package/nexus-stage/src/planning-overlap.ts +159 -159
  40. package/nexus-stage/src/planning-reachability.ts +28 -28
  41. package/nexus-stage/src/planning-relayout.ts +121 -121
  42. package/nexus-stage/src/planning-roster.ts +15 -15
  43. package/nexus-stage/src/planning-search.ts +168 -168
  44. package/nexus-stage/src/planning-types.ts +115 -115
  45. package/nexus-stage/src/planning-undo.ts +68 -68
  46. package/nexus-stage/src/planning-view.ts +1499 -1499
  47. package/nexus-stage/src/planning.css +340 -340
  48. package/nexus-stage/src/seat-name-editor.ts +31 -31
  49. package/nexus-stage/src/session-drag.ts +64 -64
  50. package/nexus-stage/src/viewport.ts +106 -106
  51. package/nexus-stage/src/wb-chat.ts +684 -684
  52. package/nexus-stage/src/wb-directory.ts +237 -237
  53. package/nexus-stage/src/wb-ledger.ts +279 -279
  54. package/nexus-stage/src/wb-names.ts +43 -43
  55. package/nexus-stage/src/wb-rail.ts +147 -147
  56. package/nexus-stage/src/workbench.css +388 -388
  57. package/nexus-stage/src/workbench.ts +1272 -1272
  58. package/nexus-stage/src/world.ts +509 -509
  59. package/nexus-stage/tsconfig.json +24 -24
  60. package/nexus-stage/vite.config.ts +15 -15
  61. package/package.json +1 -1
@@ -1,279 +1,279 @@
1
- /**
2
- * The 台账 tab (2026-10-23 redesign, user ruling — supersedes the b324460
3
- * layout while keeping its intent: results first, color only for what needs
4
- * a hand).
5
- *
6
- * Top: four stat tiles (待回执 · 陈旧 · 死信 · 已归档), each carrying its
7
- * definition; the archive verdict split as one proportional bar. Below: the
8
- * rows grouped by what to do with them —
9
- * 死信 red, 'unconsumed' (or unspecified) dead rows, with 清账;
10
- * 陈旧 amber, our receipt-expected sends ≥1h old — 可催办;
11
- * 待本机回执 a peer's delivery to this host whose receipt WE owe
12
- * (amber from 1h, its own overdue count); never "可催办";
13
- * 等待回执 quiet, our sends still within their hour;
14
- * 免回执 muted, receiptExpected:false rows (no receipt will ever come);
15
- * 已过期(免回执) muted, 'fire-forget' expiries — expected bookkeeping
16
- * (task-ledger sweep), not dead letters; 清账 still tidies them.
17
- * The stale and dead counts come from ledgerFacts(), shared with the header
18
- * verdict so the two never disagree (PR #30 review L1/L3).
19
- * Each card leads with the brief, then a muted meta line (direction, team,
20
- * age, task id) and chips (可结算 / 入站). A card whose team resolves to a
21
- * session or remote node opens that conversation. Forensic counters
22
- * (idempotency, reconcile) fold into a 取证 details element.
23
- * Rows are attacker-shaped data: textContent only.
24
- * @module nexus-stage/wb-ledger
25
- */
26
- import type { DeadRow, PendingRow, StateBody } from './workbench'
27
- import type { RailTone } from './wb-rail'
28
- import { nameInto } from './wb-names'
29
-
30
- export interface LedgerContext {
31
- readonly state: StateBody
32
- readonly now: number
33
- abandon(taskId: string, reason: string): Promise<boolean>
34
- /** Open the conversation for a routing team; false when no node resolves. */
35
- openTeam(team: string): boolean
36
- /** Whether openTeam would resolve (cards only become clickable when it does). */
37
- canOpenTeam(team: string): boolean
38
- }
39
-
40
- const HOUR = 60 * 60_000
41
-
42
- /** Inbound rows carry this host's own team; the sender is in `peer` as
43
- * `local:<sender>`. Rows say their direction (dead rows too since gh#31);
44
- * on an older host whose dead rows omit it, the peer prefix is the tell
45
- * (outbound local sends read 'local', remote ones a URL). */
46
- function isInbound(row: { direction?: string; peer?: string }): boolean {
47
- return row.direction === 'inbound' || (row.direction === undefined && row.peer?.startsWith('local:') === true)
48
- }
49
-
50
- function senderOf(row: { peer?: string }): string | undefined {
51
- return row.peer?.startsWith('local:') === true ? row.peer.slice('local:'.length) : undefined
52
- }
53
-
54
- /** Past its hour, by the host's clock when the row carries the server's
55
- * settleable verdict (receiptExpected && age < 1h, computed host-side), else
56
- * by ours — a workbench opened from another machine with a skewed clock then
57
- * still draws the boundary where the server does (at most one poll late). */
58
- export function overHour(r: PendingRow, now: number): boolean {
59
- return r.settleable !== undefined ? r.settleable === false : now - r.startedAt >= HOUR
60
- }
61
-
62
- /** The ledger's action counts, one definition for the 台账 tab and the header:
63
- * stale = OUR receipt-expected sends past their hour (receiptless rows are
64
- * never stale — no receipt is coming); owedOverdue = peers' deliveries whose
65
- * receipt WE have owed for over an hour (their own count, never mixed into
66
- * stale); dead letters = dead rows that are not a 'fire-forget' expiry. */
67
- export function ledgerFacts(state: StateBody, now: number): { stale: PendingRow[]; owedOverdue: PendingRow[]; deadLetters: DeadRow[]; expired: DeadRow[] } {
68
- const expected = (state.tasks ?? []).filter(r => r.receiptExpected !== false)
69
- const stale = expected.filter(r => !isInbound(r) && overHour(r, now))
70
- const owedOverdue = expected.filter(r => isInbound(r) && overHour(r, now))
71
- const dead = state.tasksDead ?? []
72
- return { stale, owedOverdue, deadLetters: dead.filter(r => r.reason !== 'fire-forget'), expired: dead.filter(r => r.reason === 'fire-forget') }
73
- }
74
-
75
- function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
76
- const node = document.createElement(tag)
77
- if (className !== undefined) node.className = className
78
- return node
79
- }
80
-
81
- function ageText(ms: number, now: number): string {
82
- const s = Math.max(0, Math.round((now - ms) / 1000))
83
- if (s < 90) return `${s}s`
84
- const m = Math.round(s / 60)
85
- if (m < 90) return `${m}m`
86
- const h = Math.round(m / 60)
87
- if (h < 48) return `${h}h`
88
- return `${Math.round(h / 24)}d`
89
- }
90
-
91
- function tile(parent: HTMLElement, label: string, value: number, hint: string, tone?: 'warn' | 'danger'): void {
92
- const t = el('div', `wb-led-tile${tone !== undefined ? ` is-${tone}` : ''}`)
93
- t.title = hint
94
- t.appendChild(el('span', 'wb-led-tile-v')).textContent = String(value)
95
- t.appendChild(el('span', 'wb-led-tile-k')).textContent = label
96
- parent.appendChild(t)
97
- }
98
-
99
- /** The archive's verdict vocabulary, in a fixed order: the receipt
100
- * envelope's controlled outcomes (src/receipt.ts RECEIPT_OUTCOMES) plus the
101
- * projection's 'unclassified' (a receipt that rode no envelope outcome).
102
- * 'abandoned' also covers a caller's cancel — the ledger archives both the
103
- * same way. Each verdict has a token-driven tone class, shared by the split
104
- * bar here and the chat drawer's settled status. */
105
- export const VERDICTS: ReadonlyArray<{ key: string; label: string; tone: 'completed' | 'rejected' | 'failed' | 'abandoned' | 'unclassified' }> = [
106
- { key: 'completed', label: '完成', tone: 'completed' },
107
- { key: 'sync_completed', label: '同步完成', tone: 'completed' },
108
- { key: 'rejected', label: '拒绝', tone: 'rejected' },
109
- { key: 'failed', label: '失败', tone: 'failed' },
110
- { key: 'expired', label: '过期', tone: 'abandoned' },
111
- { key: 'abandoned', label: '放弃', tone: 'abandoned' },
112
- { key: 'unknown', label: '结论不明', tone: 'unclassified' },
113
- { key: 'unclassified', label: '未分类', tone: 'unclassified' },
114
- ]
115
-
116
- function splitBar(parent: HTMLElement, split: Readonly<Record<string, number>>, total: number): void {
117
- const wrap = el('div', 'wb-led-split')
118
- const bar = el('div', 'wb-led-split-bar')
119
- bar.setAttribute('role', 'img')
120
- const legend = el('div', 'wb-led-split-legend')
121
- const known = new Set(VERDICTS.map(v => v.key))
122
- const entries: Array<[string, string, number]> = VERDICTS.map(v => [v.tone, v.label, split[v.key] ?? 0])
123
- for (const [k, n] of Object.entries(split)) if (!known.has(k)) entries.push(['other', k, n])
124
- const parts: string[] = []
125
- for (const [tone, label, n] of entries) {
126
- if (n <= 0) continue
127
- const seg = el('span', `wb-led-seg is-${tone}`)
128
- seg.style.width = `${Math.max(2, (n / total) * 100)}%`
129
- bar.appendChild(seg)
130
- const item = el('span', 'wb-led-split-item')
131
- item.appendChild(el('i', `wb-led-swatch is-${tone}`))
132
- item.appendChild(document.createTextNode(`${label} ${n}`))
133
- legend.appendChild(item)
134
- parts.push(`${label} ${n}`)
135
- }
136
- bar.setAttribute('aria-label', `归档结局:${parts.join(',')}`)
137
- wrap.append(bar, legend)
138
- parent.appendChild(wrap)
139
- }
140
-
141
- type Card = { brief?: string; team: string; sender?: string; taskId: string; atText: string; inbound: boolean; chips: string[]; tone: 'danger' | 'warn' | 'muted' | 'quiet'; dead?: DeadRow }
142
-
143
- export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: string; tone?: RailTone } {
144
- pane.replaceChildren()
145
- const { state, now } = ctx
146
- const pending = state.tasks ?? []
147
- const dead = state.tasksDead ?? []
148
- const archived = state.archivedCount ?? 0
149
- const facts = ledgerFacts(state, now)
150
- const receiptless = pending.filter(r => r.receiptExpected === false)
151
- const expected = pending.filter(r => r.receiptExpected !== false)
152
- const inbound = expected.filter(r => isInbound(r))
153
- const outbound = expected.filter(r => !isInbound(r))
154
- const stale = facts.stale
155
- const young = outbound.filter(r => !overHour(r, now))
156
- const staleCount = facts.stale.length
157
-
158
- const tiles = el('div', 'wb-led-tiles')
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} 条不计` : ''}`)
163
- tile(tiles, '陈旧', staleCount, '我方派发、要回执、已等待 ≥1h 仍未回执(可催办);本机欠对方的入站行在「待本机回执」组另计,免回执行不算陈旧', staleCount > 0 ? 'warn' : undefined)
164
- tile(tiles, '死信', facts.deadLetters.length, '超过 24h TTL 仍未结算(unconsumed);需清账(abandon)才能移出。免回执到期另列,不算死信', facts.deadLetters.length > 0 ? 'danger' : undefined)
165
- tile(tiles, '已归档', archived, '已离开台账并留档的任务(结算、拒绝或放弃),用于审计')
166
- pane.appendChild(tiles)
167
- if (archived > 0 && state.archivedSplit !== undefined) splitBar(pane, state.archivedSplit, archived)
168
-
169
- const card = (parent: HTMLElement, c: Card): void => {
170
- const node = el('div', `wb-led-card is-${c.tone}`)
171
- const head = el('div', 'wb-led-card-head')
172
- head.appendChild(el('span', `wb-led-dir${c.inbound ? ' is-in' : ''}`)).textContent = c.inbound ? '↙' : '↗'
173
- const brief = el('span', 'wb-led-brief')
174
- brief.textContent = c.brief !== undefined && c.brief !== '' ? c.brief : '(无摘要——派发未带 brief)'
175
- if (c.brief === undefined || c.brief === '') brief.classList.add('is-missing')
176
- head.appendChild(brief)
177
- node.appendChild(head)
178
- const meta = el('div', 'wb-led-meta')
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, '至 ')
186
- meta.appendChild(el('span')).textContent = c.atText
187
- meta.appendChild(el('span', 'wb-led-task')).textContent = c.taskId
188
- for (const chip of c.chips) meta.appendChild(el('span', 'wb-led-chip')).textContent = chip
189
- node.appendChild(meta)
190
- node.title = `${c.taskId}\n${c.team}`
191
- if (c.dead !== undefined) {
192
- const row = c.dead
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' : ''}`)
197
- btn.type = 'button'
198
- btn.textContent = expiry ? '移出' : '清账'
199
- btn.title = expiry ? '可选:把这条已过期的免回执记录移出台账(不是失败,不处理也无妨)' : '从台账移出这条死信(abandon)'
200
- btn.addEventListener('click', ev => {
201
- ev.stopPropagation()
202
- btn.disabled = true
203
- void ctx.abandon(row.taskId, 'workbench dead-letter cleanup').then(ok => {
204
- btn.disabled = false
205
- btn.textContent = ok ? '已清' : '失败'
206
- })
207
- })
208
- node.appendChild(btn)
209
- }
210
- if (ctx.canOpenTeam(c.team)) {
211
- node.classList.add('is-link')
212
- node.setAttribute('role', 'button')
213
- node.tabIndex = 0
214
- node.setAttribute('aria-label', `${brief.textContent},打开 ${c.team} 的对话`)
215
- const go = (): void => { ctx.openTeam(c.team) }
216
- node.addEventListener('click', go)
217
- node.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); go() } })
218
- }
219
- parent.appendChild(node)
220
- }
221
- const section = (label: string, rows: number, tone: string, hint: string): HTMLElement | null => {
222
- if (rows === 0) return null
223
- const g = el('div', `wb-led-group is-${tone}`)
224
- g.dataset.group = label
225
- const head = el('div', 'wb-led-group-head')
226
- head.appendChild(el('span')).textContent = `${label} · ${rows}`
227
- head.appendChild(el('span', 'wb-led-group-hint')).textContent = hint
228
- g.appendChild(head)
229
- pane.appendChild(g)
230
- return g
231
- }
232
- const pendingCard = (r: PendingRow, tone: Card['tone']): Card => {
233
- const inb = isInbound(r)
234
- return {
235
- brief: r.brief, team: r.team, sender: senderOf(r), taskId: r.taskId, atText: `${ageText(r.startedAt, now)} 前${inb ? '收到' : '派发'}`, inbound: inb, tone,
236
- chips: [...(r.settleable === true ? ['可结算'] : []), ...(r.receiptExpected === false ? ['无回执债'] : []), ...(inb ? ['入站'] : []), ...(r.status !== 'pending' ? [r.status] : [])],
237
- }
238
- }
239
- const deadCard = (r: DeadRow, tone: Card['tone'], label: string): Card => ({
240
- brief: r.brief, team: r.team, sender: senderOf(r), taskId: r.taskId, atText: `${ageText(r.deadAt, now)} 前${label}`, inbound: isInbound(r), tone, dead: r,
241
- chips: r.reason !== undefined && r.reason !== '' && r.reason !== 'unspecified' && r.reason !== 'fire-forget' ? [r.reason] : [],
242
- })
243
-
244
- const gDead = section('死信', facts.deadLetters.length, 'danger', '需清账')
245
- if (gDead !== null) for (const r of [...facts.deadLetters].sort((a, b) => b.deadAt - a.deadAt)) card(gDead, deadCard(r, 'danger', '死信'))
246
- const gStale = section('陈旧', stale.length, 'warn', '我方派发 ≥1 小时仍无回执,可催办')
247
- if (gStale !== null) for (const r of [...stale].sort((a, b) => a.startedAt - b.startedAt)) card(gStale, pendingCard(r, 'warn'))
248
- const overdue = facts.owedOverdue.length
249
- const gOwe = section('待本机回执', inbound.length, overdue > 0 ? 'warn' : 'quiet', overdue > 0 ? `对方在等本机回执 · ${overdue} 条超过 1 小时` : '对方在等本机回执')
250
- if (gOwe !== null) for (const r of [...inbound].sort((a, b) => a.startedAt - b.startedAt)) card(gOwe, pendingCard(r, overHour(r, now) ? 'warn' : 'quiet'))
251
- const gWait = section('等待回执', young.length, 'quiet', '1 小时内')
252
- if (gWait !== null) for (const r of [...young].sort((a, b) => b.startedAt - a.startedAt)) card(gWait, pendingCard(r, 'quiet'))
253
- const gFree = section('免回执', receiptless.length, 'muted', '不等回执,24h 后自动归档')
254
- if (gFree !== null) for (const r of receiptless) card(gFree, pendingCard(r, 'muted'))
255
- const gExpired = section('已过期(免回执)', facts.expired.length, 'muted', '正常记账,不是失败')
256
- if (gExpired !== null) for (const r of [...facts.expired].sort((a, b) => b.deadAt - a.deadAt)) card(gExpired, deadCard(r, 'muted', '过期'))
257
-
258
- if (pending.length + dead.length === 0) {
259
- pane.appendChild(el('div', 'wb-led-empty')).textContent = '台账为空——没有待回执的任务'
260
- }
261
-
262
- // Forensics: the plumbing counters a seat reads when something looks off.
263
- const idem = state.idempotency
264
- const reconcileState = state.reconcile?.state
265
- if (idem !== undefined || reconcileState !== undefined) {
266
- const details = el('details', 'wb-led-forensics wb-forensics')
267
- details.appendChild(el('summary')).textContent = '取证'
268
- const body = el('div', 'wb-led-forensics-body')
269
- if (idem !== undefined) body.appendChild(el('div')).textContent = `幂等 claims ${idem.claimsFresh ?? 0} · replays ${idem.replays ?? 0} · conflicts ${idem.conflicts ?? 0}`
270
- if (reconcileState !== undefined) body.appendChild(el('div')).textContent = `reconcile ${reconcileState}`
271
- details.appendChild(body)
272
- pane.appendChild(details)
273
- }
274
-
275
- // Owed = receipts still expected + dead letters to clean; receiptless rows
276
- // and fire-forget expiries owe nothing.
277
- const owed = expected.length + facts.deadLetters.length
278
- return { badge: owed > 0 ? String(owed) : '', tone: facts.deadLetters.length > 0 ? 'danger' : staleCount > 0 || overdue > 0 ? 'warn' : undefined }
279
- }
1
+ /**
2
+ * The 台账 tab (2026-10-23 redesign, user ruling — supersedes the b324460
3
+ * layout while keeping its intent: results first, color only for what needs
4
+ * a hand).
5
+ *
6
+ * Top: four stat tiles (待回执 · 陈旧 · 死信 · 已归档), each carrying its
7
+ * definition; the archive verdict split as one proportional bar. Below: the
8
+ * rows grouped by what to do with them —
9
+ * 死信 red, 'unconsumed' (or unspecified) dead rows, with 清账;
10
+ * 陈旧 amber, our receipt-expected sends ≥1h old — 可催办;
11
+ * 待本机回执 a peer's delivery to this host whose receipt WE owe
12
+ * (amber from 1h, its own overdue count); never "可催办";
13
+ * 等待回执 quiet, our sends still within their hour;
14
+ * 免回执 muted, receiptExpected:false rows (no receipt will ever come);
15
+ * 已过期(免回执) muted, 'fire-forget' expiries — expected bookkeeping
16
+ * (task-ledger sweep), not dead letters; 清账 still tidies them.
17
+ * The stale and dead counts come from ledgerFacts(), shared with the header
18
+ * verdict so the two never disagree (PR #30 review L1/L3).
19
+ * Each card leads with the brief, then a muted meta line (direction, team,
20
+ * age, task id) and chips (可结算 / 入站). A card whose team resolves to a
21
+ * session or remote node opens that conversation. Forensic counters
22
+ * (idempotency, reconcile) fold into a 取证 details element.
23
+ * Rows are attacker-shaped data: textContent only.
24
+ * @module nexus-stage/wb-ledger
25
+ */
26
+ import type { DeadRow, PendingRow, StateBody } from './workbench'
27
+ import type { RailTone } from './wb-rail'
28
+ import { nameInto } from './wb-names'
29
+
30
+ export interface LedgerContext {
31
+ readonly state: StateBody
32
+ readonly now: number
33
+ abandon(taskId: string, reason: string): Promise<boolean>
34
+ /** Open the conversation for a routing team; false when no node resolves. */
35
+ openTeam(team: string): boolean
36
+ /** Whether openTeam would resolve (cards only become clickable when it does). */
37
+ canOpenTeam(team: string): boolean
38
+ }
39
+
40
+ const HOUR = 60 * 60_000
41
+
42
+ /** Inbound rows carry this host's own team; the sender is in `peer` as
43
+ * `local:<sender>`. Rows say their direction (dead rows too since gh#31);
44
+ * on an older host whose dead rows omit it, the peer prefix is the tell
45
+ * (outbound local sends read 'local', remote ones a URL). */
46
+ function isInbound(row: { direction?: string; peer?: string }): boolean {
47
+ return row.direction === 'inbound' || (row.direction === undefined && row.peer?.startsWith('local:') === true)
48
+ }
49
+
50
+ function senderOf(row: { peer?: string }): string | undefined {
51
+ return row.peer?.startsWith('local:') === true ? row.peer.slice('local:'.length) : undefined
52
+ }
53
+
54
+ /** Past its hour, by the host's clock when the row carries the server's
55
+ * settleable verdict (receiptExpected && age < 1h, computed host-side), else
56
+ * by ours — a workbench opened from another machine with a skewed clock then
57
+ * still draws the boundary where the server does (at most one poll late). */
58
+ export function overHour(r: PendingRow, now: number): boolean {
59
+ return r.settleable !== undefined ? r.settleable === false : now - r.startedAt >= HOUR
60
+ }
61
+
62
+ /** The ledger's action counts, one definition for the 台账 tab and the header:
63
+ * stale = OUR receipt-expected sends past their hour (receiptless rows are
64
+ * never stale — no receipt is coming); owedOverdue = peers' deliveries whose
65
+ * receipt WE have owed for over an hour (their own count, never mixed into
66
+ * stale); dead letters = dead rows that are not a 'fire-forget' expiry. */
67
+ export function ledgerFacts(state: StateBody, now: number): { stale: PendingRow[]; owedOverdue: PendingRow[]; deadLetters: DeadRow[]; expired: DeadRow[] } {
68
+ const expected = (state.tasks ?? []).filter(r => r.receiptExpected !== false)
69
+ const stale = expected.filter(r => !isInbound(r) && overHour(r, now))
70
+ const owedOverdue = expected.filter(r => isInbound(r) && overHour(r, now))
71
+ const dead = state.tasksDead ?? []
72
+ return { stale, owedOverdue, deadLetters: dead.filter(r => r.reason !== 'fire-forget'), expired: dead.filter(r => r.reason === 'fire-forget') }
73
+ }
74
+
75
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
76
+ const node = document.createElement(tag)
77
+ if (className !== undefined) node.className = className
78
+ return node
79
+ }
80
+
81
+ function ageText(ms: number, now: number): string {
82
+ const s = Math.max(0, Math.round((now - ms) / 1000))
83
+ if (s < 90) return `${s}s`
84
+ const m = Math.round(s / 60)
85
+ if (m < 90) return `${m}m`
86
+ const h = Math.round(m / 60)
87
+ if (h < 48) return `${h}h`
88
+ return `${Math.round(h / 24)}d`
89
+ }
90
+
91
+ function tile(parent: HTMLElement, label: string, value: number, hint: string, tone?: 'warn' | 'danger'): void {
92
+ const t = el('div', `wb-led-tile${tone !== undefined ? ` is-${tone}` : ''}`)
93
+ t.title = hint
94
+ t.appendChild(el('span', 'wb-led-tile-v')).textContent = String(value)
95
+ t.appendChild(el('span', 'wb-led-tile-k')).textContent = label
96
+ parent.appendChild(t)
97
+ }
98
+
99
+ /** The archive's verdict vocabulary, in a fixed order: the receipt
100
+ * envelope's controlled outcomes (src/receipt.ts RECEIPT_OUTCOMES) plus the
101
+ * projection's 'unclassified' (a receipt that rode no envelope outcome).
102
+ * 'abandoned' also covers a caller's cancel — the ledger archives both the
103
+ * same way. Each verdict has a token-driven tone class, shared by the split
104
+ * bar here and the chat drawer's settled status. */
105
+ export const VERDICTS: ReadonlyArray<{ key: string; label: string; tone: 'completed' | 'rejected' | 'failed' | 'abandoned' | 'unclassified' }> = [
106
+ { key: 'completed', label: '完成', tone: 'completed' },
107
+ { key: 'sync_completed', label: '同步完成', tone: 'completed' },
108
+ { key: 'rejected', label: '拒绝', tone: 'rejected' },
109
+ { key: 'failed', label: '失败', tone: 'failed' },
110
+ { key: 'expired', label: '过期', tone: 'abandoned' },
111
+ { key: 'abandoned', label: '放弃', tone: 'abandoned' },
112
+ { key: 'unknown', label: '结论不明', tone: 'unclassified' },
113
+ { key: 'unclassified', label: '未分类', tone: 'unclassified' },
114
+ ]
115
+
116
+ function splitBar(parent: HTMLElement, split: Readonly<Record<string, number>>, total: number): void {
117
+ const wrap = el('div', 'wb-led-split')
118
+ const bar = el('div', 'wb-led-split-bar')
119
+ bar.setAttribute('role', 'img')
120
+ const legend = el('div', 'wb-led-split-legend')
121
+ const known = new Set(VERDICTS.map(v => v.key))
122
+ const entries: Array<[string, string, number]> = VERDICTS.map(v => [v.tone, v.label, split[v.key] ?? 0])
123
+ for (const [k, n] of Object.entries(split)) if (!known.has(k)) entries.push(['other', k, n])
124
+ const parts: string[] = []
125
+ for (const [tone, label, n] of entries) {
126
+ if (n <= 0) continue
127
+ const seg = el('span', `wb-led-seg is-${tone}`)
128
+ seg.style.width = `${Math.max(2, (n / total) * 100)}%`
129
+ bar.appendChild(seg)
130
+ const item = el('span', 'wb-led-split-item')
131
+ item.appendChild(el('i', `wb-led-swatch is-${tone}`))
132
+ item.appendChild(document.createTextNode(`${label} ${n}`))
133
+ legend.appendChild(item)
134
+ parts.push(`${label} ${n}`)
135
+ }
136
+ bar.setAttribute('aria-label', `归档结局:${parts.join(',')}`)
137
+ wrap.append(bar, legend)
138
+ parent.appendChild(wrap)
139
+ }
140
+
141
+ type Card = { brief?: string; team: string; sender?: string; taskId: string; atText: string; inbound: boolean; chips: string[]; tone: 'danger' | 'warn' | 'muted' | 'quiet'; dead?: DeadRow }
142
+
143
+ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: string; tone?: RailTone } {
144
+ pane.replaceChildren()
145
+ const { state, now } = ctx
146
+ const pending = state.tasks ?? []
147
+ const dead = state.tasksDead ?? []
148
+ const archived = state.archivedCount ?? 0
149
+ const facts = ledgerFacts(state, now)
150
+ const receiptless = pending.filter(r => r.receiptExpected === false)
151
+ const expected = pending.filter(r => r.receiptExpected !== false)
152
+ const inbound = expected.filter(r => isInbound(r))
153
+ const outbound = expected.filter(r => !isInbound(r))
154
+ const stale = facts.stale
155
+ const young = outbound.filter(r => !overHour(r, now))
156
+ const staleCount = facts.stale.length
157
+
158
+ const tiles = el('div', 'wb-led-tiles')
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} 条不计` : ''}`)
163
+ tile(tiles, '陈旧', staleCount, '我方派发、要回执、已等待 ≥1h 仍未回执(可催办);本机欠对方的入站行在「待本机回执」组另计,免回执行不算陈旧', staleCount > 0 ? 'warn' : undefined)
164
+ tile(tiles, '死信', facts.deadLetters.length, '超过 24h TTL 仍未结算(unconsumed);需清账(abandon)才能移出。免回执到期另列,不算死信', facts.deadLetters.length > 0 ? 'danger' : undefined)
165
+ tile(tiles, '已归档', archived, '已离开台账并留档的任务(结算、拒绝或放弃),用于审计')
166
+ pane.appendChild(tiles)
167
+ if (archived > 0 && state.archivedSplit !== undefined) splitBar(pane, state.archivedSplit, archived)
168
+
169
+ const card = (parent: HTMLElement, c: Card): void => {
170
+ const node = el('div', `wb-led-card is-${c.tone}`)
171
+ const head = el('div', 'wb-led-card-head')
172
+ head.appendChild(el('span', `wb-led-dir${c.inbound ? ' is-in' : ''}`)).textContent = c.inbound ? '↙' : '↗'
173
+ const brief = el('span', 'wb-led-brief')
174
+ brief.textContent = c.brief !== undefined && c.brief !== '' ? c.brief : '(无摘要——派发未带 brief)'
175
+ if (c.brief === undefined || c.brief === '') brief.classList.add('is-missing')
176
+ head.appendChild(brief)
177
+ node.appendChild(head)
178
+ const meta = el('div', 'wb-led-meta')
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, '至 ')
186
+ meta.appendChild(el('span')).textContent = c.atText
187
+ meta.appendChild(el('span', 'wb-led-task')).textContent = c.taskId
188
+ for (const chip of c.chips) meta.appendChild(el('span', 'wb-led-chip')).textContent = chip
189
+ node.appendChild(meta)
190
+ node.title = `${c.taskId}\n${c.team}`
191
+ if (c.dead !== undefined) {
192
+ const row = c.dead
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' : ''}`)
197
+ btn.type = 'button'
198
+ btn.textContent = expiry ? '移出' : '清账'
199
+ btn.title = expiry ? '可选:把这条已过期的免回执记录移出台账(不是失败,不处理也无妨)' : '从台账移出这条死信(abandon)'
200
+ btn.addEventListener('click', ev => {
201
+ ev.stopPropagation()
202
+ btn.disabled = true
203
+ void ctx.abandon(row.taskId, 'workbench dead-letter cleanup').then(ok => {
204
+ btn.disabled = false
205
+ btn.textContent = ok ? '已清' : '失败'
206
+ })
207
+ })
208
+ node.appendChild(btn)
209
+ }
210
+ if (ctx.canOpenTeam(c.team)) {
211
+ node.classList.add('is-link')
212
+ node.setAttribute('role', 'button')
213
+ node.tabIndex = 0
214
+ node.setAttribute('aria-label', `${brief.textContent},打开 ${c.team} 的对话`)
215
+ const go = (): void => { ctx.openTeam(c.team) }
216
+ node.addEventListener('click', go)
217
+ node.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); go() } })
218
+ }
219
+ parent.appendChild(node)
220
+ }
221
+ const section = (label: string, rows: number, tone: string, hint: string): HTMLElement | null => {
222
+ if (rows === 0) return null
223
+ const g = el('div', `wb-led-group is-${tone}`)
224
+ g.dataset.group = label
225
+ const head = el('div', 'wb-led-group-head')
226
+ head.appendChild(el('span')).textContent = `${label} · ${rows}`
227
+ head.appendChild(el('span', 'wb-led-group-hint')).textContent = hint
228
+ g.appendChild(head)
229
+ pane.appendChild(g)
230
+ return g
231
+ }
232
+ const pendingCard = (r: PendingRow, tone: Card['tone']): Card => {
233
+ const inb = isInbound(r)
234
+ return {
235
+ brief: r.brief, team: r.team, sender: senderOf(r), taskId: r.taskId, atText: `${ageText(r.startedAt, now)} 前${inb ? '收到' : '派发'}`, inbound: inb, tone,
236
+ chips: [...(r.settleable === true ? ['可结算'] : []), ...(r.receiptExpected === false ? ['无回执债'] : []), ...(inb ? ['入站'] : []), ...(r.status !== 'pending' ? [r.status] : [])],
237
+ }
238
+ }
239
+ const deadCard = (r: DeadRow, tone: Card['tone'], label: string): Card => ({
240
+ brief: r.brief, team: r.team, sender: senderOf(r), taskId: r.taskId, atText: `${ageText(r.deadAt, now)} 前${label}`, inbound: isInbound(r), tone, dead: r,
241
+ chips: r.reason !== undefined && r.reason !== '' && r.reason !== 'unspecified' && r.reason !== 'fire-forget' ? [r.reason] : [],
242
+ })
243
+
244
+ const gDead = section('死信', facts.deadLetters.length, 'danger', '需清账')
245
+ if (gDead !== null) for (const r of [...facts.deadLetters].sort((a, b) => b.deadAt - a.deadAt)) card(gDead, deadCard(r, 'danger', '死信'))
246
+ const gStale = section('陈旧', stale.length, 'warn', '我方派发 ≥1 小时仍无回执,可催办')
247
+ if (gStale !== null) for (const r of [...stale].sort((a, b) => a.startedAt - b.startedAt)) card(gStale, pendingCard(r, 'warn'))
248
+ const overdue = facts.owedOverdue.length
249
+ const gOwe = section('待本机回执', inbound.length, overdue > 0 ? 'warn' : 'quiet', overdue > 0 ? `对方在等本机回执 · ${overdue} 条超过 1 小时` : '对方在等本机回执')
250
+ if (gOwe !== null) for (const r of [...inbound].sort((a, b) => a.startedAt - b.startedAt)) card(gOwe, pendingCard(r, overHour(r, now) ? 'warn' : 'quiet'))
251
+ const gWait = section('等待回执', young.length, 'quiet', '1 小时内')
252
+ if (gWait !== null) for (const r of [...young].sort((a, b) => b.startedAt - a.startedAt)) card(gWait, pendingCard(r, 'quiet'))
253
+ const gFree = section('免回执', receiptless.length, 'muted', '不等回执,24h 后自动归档')
254
+ if (gFree !== null) for (const r of receiptless) card(gFree, pendingCard(r, 'muted'))
255
+ const gExpired = section('已过期(免回执)', facts.expired.length, 'muted', '正常记账,不是失败')
256
+ if (gExpired !== null) for (const r of [...facts.expired].sort((a, b) => b.deadAt - a.deadAt)) card(gExpired, deadCard(r, 'muted', '过期'))
257
+
258
+ if (pending.length + dead.length === 0) {
259
+ pane.appendChild(el('div', 'wb-led-empty')).textContent = '台账为空——没有待回执的任务'
260
+ }
261
+
262
+ // Forensics: the plumbing counters a seat reads when something looks off.
263
+ const idem = state.idempotency
264
+ const reconcileState = state.reconcile?.state
265
+ if (idem !== undefined || reconcileState !== undefined) {
266
+ const details = el('details', 'wb-led-forensics wb-forensics')
267
+ details.appendChild(el('summary')).textContent = '取证'
268
+ const body = el('div', 'wb-led-forensics-body')
269
+ if (idem !== undefined) body.appendChild(el('div')).textContent = `幂等 claims ${idem.claimsFresh ?? 0} · replays ${idem.replays ?? 0} · conflicts ${idem.conflicts ?? 0}`
270
+ if (reconcileState !== undefined) body.appendChild(el('div')).textContent = `reconcile ${reconcileState}`
271
+ details.appendChild(body)
272
+ pane.appendChild(details)
273
+ }
274
+
275
+ // Owed = receipts still expected + dead letters to clean; receiptless rows
276
+ // and fire-forget expiries owe nothing.
277
+ const owed = expected.length + facts.deadLetters.length
278
+ return { badge: owed > 0 ? String(owed) : '', tone: facts.deadLetters.length > 0 ? 'danger' : staleCount > 0 || overdue > 0 ? 'warn' : undefined }
279
+ }