@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24

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 (86) hide show
  1. package/README.md +243 -121
  2. package/README_zh.md +144 -84
  3. package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
  4. package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
  5. package/assets/nexusDist/index.html +26 -0
  6. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
  7. package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
  8. package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
  9. package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
  10. package/assets/stageDist/index.html +13 -0
  11. package/lib/a2a-client.js +190 -13
  12. package/lib/canvas-store.js +218 -0
  13. package/lib/card.js +25 -0
  14. package/lib/client/A2aControl.js +45 -137
  15. package/lib/client/locales.js +16 -40
  16. package/lib/client.js +164 -467
  17. package/lib/client.js.map +1 -1
  18. package/lib/group-store.js +6 -0
  19. package/lib/idempotency-store.js +291 -0
  20. package/lib/index.js +4217 -259
  21. package/lib/joined-store.js +11 -0
  22. package/lib/layout-store.js +155 -0
  23. package/lib/peer-store.js +98 -13
  24. package/lib/receipt-ladder.js +35 -0
  25. package/lib/receipt.js +102 -0
  26. package/lib/self-suppress.js +62 -0
  27. package/lib/session-transcript.js +150 -0
  28. package/lib/skill/SKILL.md +77 -0
  29. package/lib/skill/references/discovery.md +38 -0
  30. package/lib/skill/references/membership.md +50 -0
  31. package/lib/skill/references/pitfalls.md +42 -0
  32. package/lib/skill/references/routing.md +43 -0
  33. package/lib/skill-registration.js +52 -0
  34. package/lib/stage-mount.js +32 -0
  35. package/lib/task-ledger.js +540 -0
  36. package/lib/team-store.js +122 -0
  37. package/lib/teams-bridge.js +18 -0
  38. package/lib/transport-caps.js +31 -0
  39. package/lib/types/a2a-client.d.ts +72 -1
  40. package/lib/types/canvas-store.d.ts +102 -0
  41. package/lib/types/client/A2aControl.d.ts +18 -28
  42. package/lib/types/client/locales.d.ts +1 -1
  43. package/lib/types/group-store.d.ts +6 -0
  44. package/lib/types/idempotency-store.d.ts +164 -0
  45. package/lib/types/index.d.ts +210 -6
  46. package/lib/types/joined-store.d.ts +9 -0
  47. package/lib/types/layout-store.d.ts +63 -0
  48. package/lib/types/peer-store.d.ts +45 -2
  49. package/lib/types/receipt-ladder.d.ts +25 -0
  50. package/lib/types/receipt.d.ts +51 -0
  51. package/lib/types/self-suppress.d.ts +41 -0
  52. package/lib/types/session-transcript.d.ts +37 -0
  53. package/lib/types/skill-registration.d.ts +3 -0
  54. package/lib/types/stage-mount.d.ts +29 -0
  55. package/lib/types/task-ledger.d.ts +308 -0
  56. package/lib/types/team-store.d.ts +55 -0
  57. package/lib/types/teams-bridge.d.ts +112 -0
  58. package/lib/types/transport-caps.d.ts +29 -0
  59. package/lib/types/types.d.ts +43 -0
  60. package/nexus-stage/package-lock.json +2262 -0
  61. package/nexus-stage/package.json +21 -0
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  63. package/nexus-stage/scripts/mockhost.py +253 -0
  64. package/nexus-stage/src/canvas-ops.ts +175 -0
  65. package/nexus-stage/src/canvas-wire.ts +156 -0
  66. package/nexus-stage/src/edges.ts +72 -0
  67. package/nexus-stage/src/fault.ts +56 -0
  68. package/nexus-stage/src/federation.ts +234 -0
  69. package/nexus-stage/src/layout-doc.ts +143 -0
  70. package/nexus-stage/src/layout-wire.ts +158 -0
  71. package/nexus-stage/src/main.ts +361 -0
  72. package/nexus-stage/src/planning-menus.ts +352 -0
  73. package/nexus-stage/src/planning-nodelist.ts +294 -0
  74. package/nexus-stage/src/planning-types.ts +111 -0
  75. package/nexus-stage/src/planning-view.ts +1313 -0
  76. package/nexus-stage/src/planning.css +280 -0
  77. package/nexus-stage/src/seat.ts +35 -0
  78. package/nexus-stage/src/stage-name.ts +13 -0
  79. package/nexus-stage/src/tokens.ts +32 -0
  80. package/nexus-stage/src/viewport.ts +83 -0
  81. package/nexus-stage/src/workbench.css +267 -0
  82. package/nexus-stage/src/workbench.ts +1530 -0
  83. package/nexus-stage/src/world.ts +509 -0
  84. package/nexus-stage/tsconfig.json +25 -0
  85. package/nexus-stage/vite.config.ts +16 -0
  86. package/package.json +83 -22
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The stage's fault surface as pure logic: whoever calls {@link fault}
3
+ * gets the newest message on the sink immediately, while the diagnostic
4
+ * log line is throttled to one per window (the N2 breadcrumb cadence).
5
+ * {@link clear} hides the sink and resets the throttle clock, so the first
6
+ * fault after a healthy stretch logs promptly again.
7
+ *
8
+ * DOM-free on purpose: the browser shell in main.ts supplies the sink, and
9
+ * the unit test supplies fakes — the lifecycle (sink-always-current,
10
+ * log-throttled, clear-resets) is what the regression net owns. This module
11
+ * exists because the incident class it guards against (a catch burying a
12
+ * ReferenceError behind a healthy render loop) shipped once already.
13
+ * @module nexus-stage/fault
14
+ */
15
+
16
+ /** How long one diagnostic log line suppresses its successors. */
17
+ export const FAULT_LOG_THROTTLE_MS = 60_000
18
+
19
+ /** Anything the fault reporter can render onto. */
20
+ export interface FaultSink {
21
+ /** Surface the newest fault; the sink becomes visible with this text. */
22
+ show(message: string): void
23
+ /** Retract the sink after a healthy response. */
24
+ hide(): void
25
+ }
26
+
27
+ export interface FaultReporter {
28
+ /** Surface a fault: the sink always updates, the log line throttles. */
29
+ fault(message: string): void
30
+ /** A healthy response arrived: hide the sink and reset the throttle. */
31
+ clear(): void
32
+ }
33
+
34
+ /**
35
+ * @param sink - render target (DOM badge in the shell, recording fake in tests).
36
+ * @param log - diagnostic channel (console.error in the shell, spy in tests).
37
+ * @param now - injectable clock for deterministic throttle tests. A fault
38
+ * exactly one window after the previous logs again (window is inclusive).
39
+ */
40
+ export function createFaultReporter(sink: FaultSink, log: (message: string) => void, now: () => number = Date.now): FaultReporter {
41
+ let lastLoggedAt = -FAULT_LOG_THROTTLE_MS
42
+ return {
43
+ fault(message) {
44
+ const at = now()
45
+ if (at - lastLoggedAt >= FAULT_LOG_THROTTLE_MS) {
46
+ log(`[nexus] ${message}`)
47
+ lastLoggedAt = at
48
+ }
49
+ sink.show(message)
50
+ },
51
+ clear() {
52
+ sink.hide()
53
+ lastLoggedAt = -FAULT_LOG_THROTTLE_MS
54
+ },
55
+ }
56
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Federation overlay geometry for the 2D planning canvas (PR D 活动/联邦
3
+ * 层): where peer badges sit, which accent edges carry the pending a2a
4
+ * routes, and where the indigo federal referral lines attach. Everything
5
+ * is derived from the polling state (`/__dsh_a2a/state` -> inFlight,
6
+ * peers) plus the team-frame anchors, so the DOM layer only draws what
7
+ * these functions return - same derivation discipline as edges.ts.
8
+ *
9
+ * Pure: no DOM, no three, no clock. `now` is always a parameter so the
10
+ * suite pins time deterministically. All coordinates are world-space;
11
+ * the transformed #world container carries them to the screen for free.
12
+ * @module nexus-stage/federation
13
+ */
14
+
15
+ import type { WorldBounds } from './viewport'
16
+
17
+ /** One pending outbound route from /state `inFlight`. */
18
+ export interface InFlightRow {
19
+ team: string
20
+ /** peer as `host:port` (no scheme) - the badge url carries the scheme. */
21
+ peer: string
22
+ startedAt: number
23
+ }
24
+
25
+ /** One federation peer from /state `peers`. */
26
+ export interface PeerRow {
27
+ url: string
28
+ score?: number
29
+ }
30
+
31
+ /** A team frame's titlebar anchor {fx + fw/2, fy + 11} (edges.ts lockstep). */
32
+ export interface FrameAnchor {
33
+ name: string
34
+ x: number
35
+ y: number
36
+ }
37
+
38
+ /** A peer badge's resolved slot in the placement column. */
39
+ export interface PeerPlacement {
40
+ url: string
41
+ score?: number
42
+ x: number
43
+ y: number
44
+ }
45
+
46
+ /** One derived overlay edge, ready for the SVG / label layers. */
47
+ /** Node id for a peer endpoint: `peer-<host>:<port>` (≤128, no '/').
48
+ * Unparseable urls keep their raw text, reduced to id-safe characters —
49
+ * attacker-shaped urls collapse to one id instead of poisoning selectors. */
50
+ export function peerNodeId(url: string): string {
51
+ const host = peerHostOf(url).replace(/[^A-Za-z0-9.:\[\]-]/g, '-')
52
+ return `peer-${host}`
53
+ }
54
+
55
+ /** Bare host:port of a peer url (scheme stripped; unparseable urls pass through). */
56
+ export function peerHostOf(url: string): string {
57
+ try {
58
+ return new URL(url).host
59
+ } catch {
60
+ return url
61
+ }
62
+ }
63
+
64
+ /** One remote team row as the directory reports it (state.remote). */
65
+ export interface RemoteTeamRow {
66
+ team: string
67
+ name?: string
68
+ via?: string
69
+ workspace?: string
70
+ /** Publishing node's label + LAN IP — the host grouping dimension. */
71
+ origin?: string
72
+ /** Roster teams the publishing node declared for this handle (card teamMemberships). */
73
+ teams?: readonly string[]
74
+ }
75
+
76
+ /**
77
+ * Group remote team rows by the peer endpoint they arrived through. Rows
78
+ * without a usable `via` land under '' (skipped by peer card rendering).
79
+ * Key = bare host:port.
80
+ */
81
+ export function groupRemoteTeams(rows: ReadonlyArray<RemoteTeamRow>): Map<string, RemoteTeamRow[]> {
82
+ const out = new Map<string, RemoteTeamRow[]>()
83
+ for (const row of rows) {
84
+ const via = row.via !== undefined && row.via !== '' ? peerHostOf(row.via) : ''
85
+ const list = out.get(via) ?? []
86
+ list.push(row)
87
+ out.set(via, list)
88
+ }
89
+ return out
90
+ }
91
+
92
+ export interface ActivityEdge {
93
+ x1: number
94
+ y1: number
95
+ x2: number
96
+ y2: number
97
+ label: string
98
+ }
99
+
100
+ /** Badge column x-offset right of the frames' content bounds (maxX + gap). */
101
+ export const PEER_COLUMN_GAP = 140
102
+ /** Fallback column x while the canvas has no frames to anchor against. */
103
+ export const PEER_COLUMN_FALLBACK_X = 200
104
+ /** Vertical pitch between badges in the column. */
105
+ export const PEER_COLUMN_STEP = 90
106
+ /** Federal-line origin gap left of the content bounds (minX - gap). */
107
+ /** Fallback federal anchor offset (null bounds): x = -GAP. */
108
+ export const FEDERAL_ANCHOR_GAP = 80
109
+ /** Gutter gap between the card field's right edge and the federal anchor
110
+ * (bounds.maxX + GUTTER) — keeps the short stubs off the card field. */
111
+ export const FEDERAL_GUTTER_GAP = 20
112
+ /** Padding around every badge point when computing the fly region. */
113
+ export const FLY_PAD = 60
114
+
115
+ /**
116
+ * Lay the peer badges out in a deterministic column: one x for all,
117
+ * y stacked from the content bounds' top at a fixed 90px pitch, in the
118
+ * peers array's order. Recomputed every reconcile - the column tracks
119
+ * the content, never the mouse. Null bounds (empty canvas) fall back to
120
+ * a fixed origin so first paint is still deterministic.
121
+ */
122
+ export function placePeers(
123
+ peers: ReadonlyArray<PeerRow>,
124
+ bounds: WorldBounds | null,
125
+ ): PeerPlacement[] {
126
+ const x = bounds !== null ? bounds.maxX + PEER_COLUMN_GAP : PEER_COLUMN_FALLBACK_X
127
+ const y0 = bounds !== null ? bounds.minY : 0
128
+ return peers.map((peer, i) => ({
129
+ url: peer.url,
130
+ score: peer.score,
131
+ x,
132
+ y: y0 + i * PEER_COLUMN_STEP,
133
+ }))
134
+ }
135
+
136
+ /**
137
+ * One accent edge per pending outbound route: from the route's team
138
+ * frame titlebar anchor to the matching peer badge, labelled with the
139
+ * route and its whole-second age. Rows whose team has no frame or whose
140
+ * peer matches no badge are skipped - dangling rows are a polling
141
+ * transient, not an error. Peer match is by suffix (`host:port` against
142
+ * `scheme://host:port`), first badge in array order wins.
143
+ */
144
+ export function activityEdges(
145
+ inFlight: ReadonlyArray<InFlightRow>,
146
+ anchors: ReadonlyMap<string, FrameAnchor>,
147
+ peers: ReadonlyArray<PeerPlacement>,
148
+ now: number,
149
+ ): ActivityEdge[] {
150
+ // Merge identical (team, peer) routes into ONE edge: N concurrent routes
151
+ // over the same span would stack perfectly opaque duplicates. The label
152
+ // counts them (×N) and reports the OLDEST route's age — the one the user
153
+ // has been waiting on longest.
154
+ const merged = new Map<string, { team: string; peer: string; startedAt: number; count: number }>()
155
+ for (const row of inFlight) {
156
+ if (!anchors.has(row.team)) continue
157
+ const key = `${row.team}\u0000${row.peer}`
158
+ const cur = merged.get(key)
159
+ if (cur === undefined) merged.set(key, { team: row.team, peer: row.peer, startedAt: row.startedAt, count: 1 })
160
+ else {
161
+ cur.startedAt = Math.min(cur.startedAt, row.startedAt)
162
+ cur.count += 1
163
+ }
164
+ }
165
+
166
+ const out: ActivityEdge[] = []
167
+ for (const g of merged.values()) {
168
+ const anchor = anchors.get(g.team)
169
+ if (anchor === undefined) continue
170
+ // Match by parsed host:port — a bare endsWith would let 'badhost:port'
171
+ // satisfy a 'host:port' route.
172
+ const badge = peers.find(p => { try { return new URL(p.url).host === g.peer } catch { return false } })
173
+ if (badge === undefined) continue
174
+ const elapsed = Math.max(0, Math.round((now - g.startedAt) / 1000))
175
+ out.push({
176
+ x1: anchor.x,
177
+ y1: anchor.y,
178
+ x2: badge.x,
179
+ y2: badge.y,
180
+ label: `a2a_route · ${g.peer} · ${elapsed}s` + (g.count > 1 ? ` ×${g.count}` : ''),
181
+ })
182
+ }
183
+ return out
184
+ }
185
+
186
+ /**
187
+ * Federal referral lines: from a content-bounds anchor (minX - 80,
188
+ * vertical center) out to every peer badge. Only the first line is
189
+ * labelled "gns referral" - one legend row, not one label per peer.
190
+ * Null bounds anchors the fan at the fixed fallback origin.
191
+ */
192
+ export function federalEdges(
193
+ peers: ReadonlyArray<PeerPlacement>,
194
+ bounds: WorldBounds | null,
195
+ ): ActivityEdge[] {
196
+ // Gutter anchor: between the card field's right edge and the badge column,
197
+ // at the badges' vertical mid — short stubs that cannot cross any card
198
+ // (an anchor at the content's left middle raked long diagonals through
199
+ // the whole field: the reported wiring anomaly).
200
+ const anchor = bounds !== null
201
+ ? { x: bounds.maxX + FEDERAL_GUTTER_GAP, y: peers.length > 0 ? (peers[0]!.y + peers[peers.length - 1]!.y) / 2 : (bounds.minY + bounds.maxY) / 2 }
202
+ : { x: -FEDERAL_ANCHOR_GAP, y: 0 }
203
+ return peers.map((peer, i) => ({
204
+ x1: anchor.x,
205
+ y1: anchor.y,
206
+ x2: peer.x,
207
+ y2: peer.y,
208
+ label: i === 0 ? 'gns referral' : '',
209
+ }))
210
+ }
211
+
212
+ /**
213
+ * The fly region: frames' content bounds unioned with every badge point
214
+ * padded by FLY_PAD, so a fit over it frames the whole federation
215
+ * (canvas + column) with breathing room. Null only when there is
216
+ * nothing at all to fly to.
217
+ */
218
+ export function flyBounds(
219
+ bounds: WorldBounds | null,
220
+ peers: ReadonlyArray<PeerPlacement>,
221
+ ): WorldBounds | null {
222
+ if (bounds === null && peers.length === 0) return null
223
+ let minX = bounds !== null ? bounds.minX : Number.POSITIVE_INFINITY
224
+ let minY = bounds !== null ? bounds.minY : Number.POSITIVE_INFINITY
225
+ let maxX = bounds !== null ? bounds.maxX : Number.NEGATIVE_INFINITY
226
+ let maxY = bounds !== null ? bounds.maxY : Number.NEGATIVE_INFINITY
227
+ for (const peer of peers) {
228
+ minX = Math.min(minX, peer.x - FLY_PAD)
229
+ minY = Math.min(minY, peer.y - FLY_PAD)
230
+ maxX = Math.max(maxX, peer.x + FLY_PAD)
231
+ maxY = Math.max(maxY, peer.y + FLY_PAD)
232
+ }
233
+ return { minX, minY, maxX, maxY }
234
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Client-side mirror of the host layout contract (src/layout-store.ts).
3
+ * The host module imports node:fs, so the stage cannot share its types -
4
+ * this file redeclares the v1 document shape and reimplements the exact
5
+ * clamp discipline. Parity is not optional: the save loop adopts the
6
+ * host's normalized response, and a mirror that clamped differently from
7
+ * the host would make every save look like an external edit and keep the
8
+ * save lamp pending forever. The clamp vectors in tests/planning-world.spec.ts
9
+ * pin both sides to the same answers.
10
+ *
11
+ * Zero dependencies, no DOM - unit-testable straight from the root suite.
12
+ * @module nexus-stage/layout-doc
13
+ */
14
+
15
+ /** One draggable point in world space. */
16
+ export interface LayoutPoint { readonly x: number; readonly y: number }
17
+
18
+ /** One team-frame rectangle in world space. */
19
+ export interface LayoutRect extends LayoutPoint { readonly w: number; readonly h: number }
20
+
21
+ /** The viewport transform (world point at the top-left corner, plus zoom). */
22
+ export interface LayoutViewport extends LayoutPoint { readonly scale: number }
23
+
24
+ /** The whole persisted document (v1). */
25
+ export interface LayoutDoc {
26
+ readonly version: 1
27
+ readonly viewport: LayoutViewport
28
+ readonly nodes: Readonly<Record<string, LayoutPoint>>
29
+ readonly frames: Readonly<Record<string, LayoutRect>>
30
+ }
31
+
32
+ /** World-coordinate magnitude bound: values beyond are clamped, never trusted. */
33
+ export const COORD_LIMIT = 1_000_000
34
+ /** Zoom bounds, matching the page's wheel clamps. */
35
+ export const SCALE_MIN = 0.25
36
+ export const SCALE_MAX = 3
37
+ /** Document shape caps: a layout taxonomy, not a database. */
38
+ export const LAYOUT_NODE_CAP = 256
39
+ export const LAYOUT_FRAME_CAP = 96
40
+
41
+ function clampCoord(v: unknown): number | undefined {
42
+ const n = typeof v === 'number' ? v : Number.NaN
43
+ if (!Number.isFinite(n)) return undefined
44
+ return Math.max(-COORD_LIMIT, Math.min(COORD_LIMIT, Math.round(n)))
45
+ }
46
+
47
+ function clampPoint(raw: unknown): LayoutPoint | undefined {
48
+ if (raw === null || typeof raw !== 'object') return undefined
49
+ const rec = raw as Record<string, unknown>
50
+ const x = clampCoord(rec.x)
51
+ const y = clampCoord(rec.y)
52
+ return x === undefined || y === undefined ? undefined : { x, y }
53
+ }
54
+
55
+ function clampRect(raw: unknown): LayoutRect | undefined {
56
+ const point = clampPoint(raw)
57
+ if (point === undefined) return undefined
58
+ const rec = raw as Record<string, unknown>
59
+ const w = clampCoord(rec.w)
60
+ const h = clampCoord(rec.h)
61
+ if (w === undefined || h === undefined || w <= 0 || h <= 0) return undefined
62
+ return { ...point, w: Math.min(w, COORD_LIMIT), h: Math.min(h, COORD_LIMIT) }
63
+ }
64
+
65
+ /**
66
+ * Normalize an untrusted layout document exactly as the host does:
67
+ * version!==1 rejects the whole doc; non-finite coordinates drop the
68
+ * point; coordinates round and clamp to ±COORD_LIMIT; scale clamps to
69
+ * [SCALE_MIN, SCALE_MAX]; node keys cap at 128 chars; frame keys mirror
70
+ * canvas team names (no '/', cap 40); rects need w>0 and h>0; caps are
71
+ * first-wins in insertion order - entries beyond are silently dropped,
72
+ * never refused (an overflow renders, it just does not persist).
73
+ */
74
+ export function clampDoc(raw: unknown): LayoutDoc | undefined {
75
+ if (raw === null || typeof raw !== 'object') return undefined
76
+ const rec = raw as Record<string, unknown>
77
+ if (rec.version !== 1) return undefined
78
+ const vpRaw = rec.viewport
79
+ if (vpRaw === null || typeof vpRaw !== 'object') return undefined
80
+ const vp = vpRaw as Record<string, unknown>
81
+ const vx = clampCoord(vp.x)
82
+ const vy = clampCoord(vp.y)
83
+ const vsRaw = vp.scale
84
+ const vsNum = typeof vsRaw === 'number' ? vsRaw : Number.NaN
85
+ if (vx === undefined || vy === undefined || !Number.isFinite(vsNum)) return undefined
86
+ const scale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, vsNum))
87
+ const nodes: Record<string, LayoutPoint> = {}
88
+ if (rec.nodes !== null && typeof rec.nodes === 'object') {
89
+ for (const [id, point] of Object.entries(rec.nodes as Record<string, unknown>)) {
90
+ if (typeof id !== 'string' || id === '' || id.length > 128) continue
91
+ const p = clampPoint(point)
92
+ if (p !== undefined) nodes[id] = p
93
+ if (Object.keys(nodes).length >= LAYOUT_NODE_CAP) break
94
+ }
95
+ }
96
+ const frames: Record<string, LayoutRect> = {}
97
+ if (rec.frames !== null && typeof rec.frames === 'object') {
98
+ for (const [name, rect] of Object.entries(rec.frames as Record<string, unknown>)) {
99
+ if (typeof name !== 'string' || name === '' || name.includes('/') || name.length > 40) continue
100
+ const r = clampRect(rect)
101
+ if (r !== undefined) frames[name] = r
102
+ if (Object.keys(frames).length >= LAYOUT_FRAME_CAP) break
103
+ }
104
+ }
105
+ return { version: 1, viewport: { x: vx, y: vy, scale }, nodes, frames }
106
+ }
107
+
108
+ /**
109
+ * Build a save payload from trusted in-memory state. Pre-clamps with the
110
+ * same mirror discipline and truncates in roster insertion order, so the
111
+ * document we send is already the document the host will normalize to -
112
+ * the save response is then a fixpoint of our payload, which the save
113
+ * loop relies on to tell "our own save echoed back" from "someone else
114
+ * edited while the request was in flight".
115
+ */
116
+ export function buildLayoutDoc(
117
+ viewport: LayoutViewport,
118
+ nodes: ReadonlyMap<string, LayoutPoint>,
119
+ frames: ReadonlyMap<string, LayoutRect>,
120
+ ): LayoutDoc {
121
+ const clamp = (n: number): number => Math.max(-COORD_LIMIT, Math.min(COORD_LIMIT, Math.round(n)))
122
+ const docNodes: Record<string, LayoutPoint> = {}
123
+ for (const [id, p] of nodes) {
124
+ if (typeof id !== 'string' || id === '' || id.length > 128) continue
125
+ docNodes[id] = { x: clamp(p.x), y: clamp(p.y) }
126
+ if (Object.keys(docNodes).length >= LAYOUT_NODE_CAP) break
127
+ }
128
+ const docFrames: Record<string, LayoutRect> = {}
129
+ for (const [name, r] of frames) {
130
+ if (typeof name !== 'string' || name === '' || name.includes('/') || name.length > 40) continue
131
+ const w = clamp(r.w)
132
+ const h = clamp(r.h)
133
+ if (w <= 0 || h <= 0) continue
134
+ docFrames[name] = { x: clamp(r.x), y: clamp(r.y), w: Math.min(w, COORD_LIMIT), h: Math.min(h, COORD_LIMIT) }
135
+ if (Object.keys(docFrames).length >= LAYOUT_FRAME_CAP) break
136
+ }
137
+ return {
138
+ version: 1,
139
+ viewport: { x: clamp(viewport.x), y: clamp(viewport.y), scale: Math.max(SCALE_MIN, Math.min(SCALE_MAX, viewport.scale)) },
140
+ nodes: docNodes,
141
+ frames: docFrames,
142
+ }
143
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The planning canvas's layout save loop: debounce dirty flags into one
3
+ * POST, drive the save lamp (idle -> pending -> saved | error), and adopt
4
+ * the host's normalized snapshot without ever fighting an in-flight drag.
5
+ *
6
+ * Discipline (born of the review's "empty catch" incidents):
7
+ * - the payload is read at SEND time, so edits during the debounce window
8
+ * ride the same request;
9
+ * - on success the sent document is compared against the live one - if
10
+ * they differ, the user edited during flight: positions are NOT touched
11
+ * (the live model is authoritative), the lamp stays pending, and the
12
+ * loop chains the follow-up save. Only an unchanged document adopts the
13
+ * host's normalized response, and because the client pre-clamps with
14
+ * the host-mirror discipline, our own echo is byte-equal and adoption
15
+ * is a no-op in the common case;
16
+ * - failures surface (lamp error + the stage's throttled fault reporter),
17
+ * never swallowed - retry is explicit (click) or implicit (next edit).
18
+ *
19
+ * Pure control flow: the transport, the timer, and the clock are all
20
+ * injected, so tests pin the ladder without a network or real time.
21
+ * @module nexus-stage/layout-wire
22
+ */
23
+
24
+ /** Save-lamp states: grey=start, amber=unsaved, green=stored, red=failed. */
25
+ export type LampState = 'idle' | 'pending' | 'saved' | 'error'
26
+
27
+ /** Debounce window collapsing nudge storms into one request. */
28
+ export const SAVE_DEBOUNCE_MS = 800
29
+
30
+ /** What send() must resolve to: the host's {ok, layout?, error?} body. */
31
+ export interface SaveResponse { ok: boolean; layout?: unknown; error?: string }
32
+
33
+ export interface SaveLoopDeps {
34
+ /** The current layout document, read at send time. */
35
+ snapshot(): unknown
36
+ /** POST the document; resolve with the parsed host body, throw on transport failure. */
37
+ send(doc: unknown, opts: { keepalive: boolean }): Promise<SaveResponse>
38
+ /** Timer injection: schedule(fn, ms) returns its cancel function. */
39
+ schedule(fn: () => void, ms: number): () => void
40
+ /** Injectable clock (reserved for throttle semantics parity with fault.ts). */
41
+ now(): number
42
+ /** Lamp transitions; never called with the same state twice in a row. */
43
+ onLamp(state: LampState): void
44
+ /** Adopt the host's normalized layout (only on unchanged-document success). */
45
+ onAdopt(layout: unknown): void
46
+ /** Transport/refusal failures, for the stage's fault surface. */
47
+ onHttpError(message: string): void
48
+ }
49
+
50
+ export interface SaveLoop {
51
+ /** Something moved: lamp pending, debounced save (re-)armed. */
52
+ markDirty(): void
53
+ /** Explicit retry after an error (lamp click). */
54
+ retry(): void
55
+ /** Fire now with keepalive - the pagehide path. */
56
+ flush(): void
57
+ /** True while a request is in flight. */
58
+ isBusy(): boolean
59
+ /** Cancel the armed timer; the loop stops. */
60
+ dispose(): void
61
+ }
62
+
63
+ /** Create the save loop over the given dependencies. */
64
+ export function createSaveLoop(deps: SaveLoopDeps): SaveLoop {
65
+ let cancelTimer: (() => void) | undefined
66
+ let busy = false
67
+ let dirty = false
68
+ let flushAfterSettle = false
69
+ let lamp: LampState = 'idle'
70
+
71
+ function setLamp(next: LampState): void {
72
+ if (next === lamp) return
73
+ lamp = next
74
+ deps.onLamp(next)
75
+ }
76
+
77
+ function clearTimer(): void {
78
+ if (cancelTimer !== undefined) { cancelTimer(); cancelTimer = undefined }
79
+ }
80
+
81
+ function arm(): void {
82
+ clearTimer()
83
+ cancelTimer = deps.schedule(() => { cancelTimer = undefined; void fire(false) }, SAVE_DEBOUNCE_MS)
84
+ }
85
+
86
+ async function fire(keepalive: boolean): Promise<void> {
87
+ if (busy) { dirty = true; return }
88
+ busy = true
89
+ clearTimer()
90
+ const sent = deps.snapshot()
91
+ let sentJson: string
92
+ try {
93
+ sentJson = JSON.stringify(sent)
94
+ } catch {
95
+ busy = false
96
+ setLamp('error')
97
+ deps.onHttpError('layout document is not JSON-serializable')
98
+ return
99
+ }
100
+ try {
101
+ const res = await deps.send(sent, { keepalive })
102
+ if (res === null || typeof res !== 'object' || res.ok !== true) {
103
+ setLamp('error')
104
+ const reason = res !== null && typeof res === 'object' && typeof res.error === 'string' ? res.error : 'layout save refused'
105
+ deps.onHttpError(`layout save: ${reason}`)
106
+ } else if (JSON.stringify(deps.snapshot()) === sentJson) {
107
+ deps.onAdopt(res.layout)
108
+ setLamp('saved')
109
+ } else {
110
+ // Edited mid-flight: the live model stays authoritative; the dirty
111
+ // handling below chains the follow-up save.
112
+ }
113
+ } catch (error) {
114
+ setLamp('error')
115
+ deps.onHttpError(`layout save unreachable: ${String((error as Error | undefined)?.message ?? error).slice(0, 80)}`)
116
+ }
117
+ busy = false
118
+ if (lamp === 'error') {
119
+ // The flush request was consumed by the failed save: retry is
120
+ // explicit — do not let the stale flag chain an extra keepalive.
121
+ flushAfterSettle = false
122
+ return
123
+ }
124
+ // pagehide raced a busy save: the queued edits must not wait for a
125
+ // debounce that a closing page will never fire — flush immediately.
126
+ if (flushAfterSettle) {
127
+ flushAfterSettle = false
128
+ void fire(true)
129
+ return
130
+ }
131
+ if (dirty) {
132
+ dirty = false
133
+ setLamp('pending')
134
+ arm()
135
+ }
136
+ }
137
+
138
+ return {
139
+ markDirty() {
140
+ dirty = false
141
+ setLamp('pending')
142
+ if (busy) { dirty = true; return }
143
+ arm()
144
+ },
145
+ retry() {
146
+ if (lamp !== 'error' || busy) return
147
+ setLamp('pending')
148
+ void fire(false)
149
+ },
150
+ flush() {
151
+ if (busy) { flushAfterSettle = true; dirty = true; return }
152
+ clearTimer()
153
+ void fire(true)
154
+ },
155
+ isBusy() { return busy },
156
+ dispose() { clearTimer() },
157
+ }
158
+ }