@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
|
@@ -1,143 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,158 +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
|
-
}
|
|
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
|
+
}
|
package/nexus-stage/src/main.ts
CHANGED
|
@@ -167,7 +167,11 @@ function runCanvasAction(a: CanvasAction): Promise<boolean> {
|
|
|
167
167
|
cache: 'no-store',
|
|
168
168
|
}).catch(() => undefined)
|
|
169
169
|
const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
|
|
170
|
-
if (body?.ok !== true)
|
|
170
|
+
if (body?.ok !== true) {
|
|
171
|
+
// Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
|
|
172
|
+
planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
171
175
|
}
|
|
172
176
|
return true
|
|
173
177
|
})()
|