@nelsonlongxiang/dsh-open-a2a-net 0.6.38 → 0.6.39
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 +340 -335
- package/README_zh.md +241 -236
- package/assets/nexusDist/assets/index-C7HagaGy.css +1 -1
- package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -9
- package/cli/a2a.mjs +12 -4
- package/cli/serve.mjs +132 -0
- package/cordis.patch.yml +21 -21
- package/lib/canvas-id.js +27 -0
- package/lib/canvas-store.js +7 -1
- package/lib/client.js +63 -63
- package/lib/config.js +125 -0
- package/lib/direct-forward.js +64 -18
- package/lib/faces/admin.js +4 -1
- package/lib/faces/wire.js +24 -6
- package/lib/flood-guard.js +3 -1
- package/lib/index.js +43 -145
- package/lib/live-roots.js +39 -0
- package/lib/origin-claim.js +3 -2
- package/lib/queue-guard.js +3 -1
- package/lib/skill/references/cli.md +214 -203
- package/lib/skill/references/routing.md +4 -0
- package/lib/task-auth.js +14 -10
- package/lib/types/canvas-id.d.ts +9 -0
- package/lib/types/config.d.ts +278 -0
- package/lib/types/direct-forward.d.ts +25 -3
- package/lib/types/faces/admin.d.ts +2 -0
- package/lib/types/faces/wire.d.ts +7 -0
- package/lib/types/index.d.ts +3 -277
- package/lib/types/live-roots.d.ts +35 -0
- package/lib/types/origin-claim.d.ts +1 -0
- package/lib/types/queue-guard.d.ts +2 -0
- package/lib/types/task-auth.d.ts +3 -0
- package/lib/types/types.d.ts +2 -0
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/layout-wire.ts +158 -158
- 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/package.json +218 -218
|
Binary file
|
package/nexus-stage/src/edges.ts
CHANGED
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Star-shaped membership edges for the 2D planning canvas: one spoke per
|
|
3
|
-
* (team frame -> member card), replacing the O(n^2) pairwise mesh. All
|
|
4
|
-
* geometry is world-space so the SVG layer can emit raw coordinates - the
|
|
5
|
-
* transformed #world container carries them to the screen for free.
|
|
6
|
-
*
|
|
7
|
-
* Anchor contract (must stay in lockstep with planning.css):
|
|
8
|
-
* - origin = the frame titlebar's bottom-edge midpoint. The head is a
|
|
9
|
-
* 24px border-box straddling the frame's top border at top:-13px, so
|
|
10
|
-
* its bottom edge sits at fy + 11, horizontally centered at fx + fw/2.
|
|
11
|
-
* - target = the member card's top-edge midpoint {nx, ny - NODE_H/2}.
|
|
12
|
-
*
|
|
13
|
-
* Cross-team members draw solid from their first membership (state order)
|
|
14
|
-
* and dashed from every later one - the many-to-many invariant made
|
|
15
|
-
* visible. Edges are derived only; there is no freehand edge in this
|
|
16
|
-
* canvas. Pure: no DOM, no three.
|
|
17
|
-
* @module nexus-stage/edges
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { NODE_H } from './world'
|
|
21
|
-
import type { FrameGeom } from './world'
|
|
22
|
-
|
|
23
|
-
/** Titlebar border-box height (planning.css .frame-head must match). */
|
|
24
|
-
export const FRAME_HEAD_H = 24
|
|
25
|
-
/** Titlebar CSS top offset, negative - straddles the frame's top border. */
|
|
26
|
-
export const FRAME_HEAD_TOP = -13
|
|
27
|
-
/** Origin anchor: frame top + the head's bottom edge offset. */
|
|
28
|
-
export const FRAME_HEAD_LIFT = FRAME_HEAD_TOP + FRAME_HEAD_H
|
|
29
|
-
|
|
30
|
-
/** Minimal team shape (topology.CanvasTeam satisfies it). */
|
|
31
|
-
export interface EdgeTeam { name: string; members: ReadonlyArray<{ id: string }> }
|
|
32
|
-
|
|
33
|
-
/** One derived spoke, ready for the SVG layer. */
|
|
34
|
-
export interface StarEdge {
|
|
35
|
-
x1: number
|
|
36
|
-
y1: number
|
|
37
|
-
x2: number
|
|
38
|
-
y2: number
|
|
39
|
-
/** True from a member's second membership on (cross-team many-to-many). */
|
|
40
|
-
dashed: boolean
|
|
41
|
-
/** Team name and member id, for keyed diffing. */
|
|
42
|
-
team: string
|
|
43
|
-
id: string
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Compute every membership spoke for the current world. Frames without a
|
|
48
|
-
* rect and member ids without a position are skipped - dangling rows are
|
|
49
|
-
* a polling transient, not an error.
|
|
50
|
-
*/
|
|
51
|
-
export function starEdges(
|
|
52
|
-
teams: ReadonlyArray<EdgeTeam>,
|
|
53
|
-
frames: ReadonlyMap<string, FrameGeom>,
|
|
54
|
-
positions: ReadonlyMap<string, { readonly x: number; readonly y: number }>,
|
|
55
|
-
): StarEdge[] {
|
|
56
|
-
const out: StarEdge[] = []
|
|
57
|
-
const seen = new Set<string>()
|
|
58
|
-
for (const team of teams) {
|
|
59
|
-
const rect = frames.get(team.name)
|
|
60
|
-
if (rect === undefined) continue
|
|
61
|
-
const x1 = rect.x + rect.w / 2
|
|
62
|
-
const y1 = rect.y + FRAME_HEAD_LIFT
|
|
63
|
-
for (const member of team.members) {
|
|
64
|
-
const pos = positions.get(member.id)
|
|
65
|
-
if (pos === undefined) continue
|
|
66
|
-
const dashed = seen.has(member.id)
|
|
67
|
-
seen.add(member.id)
|
|
68
|
-
out.push({ x1, y1, x2: pos.x, y2: pos.y - NODE_H / 2, dashed, team: team.name, id: member.id })
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return out
|
|
72
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Star-shaped membership edges for the 2D planning canvas: one spoke per
|
|
3
|
+
* (team frame -> member card), replacing the O(n^2) pairwise mesh. All
|
|
4
|
+
* geometry is world-space so the SVG layer can emit raw coordinates - the
|
|
5
|
+
* transformed #world container carries them to the screen for free.
|
|
6
|
+
*
|
|
7
|
+
* Anchor contract (must stay in lockstep with planning.css):
|
|
8
|
+
* - origin = the frame titlebar's bottom-edge midpoint. The head is a
|
|
9
|
+
* 24px border-box straddling the frame's top border at top:-13px, so
|
|
10
|
+
* its bottom edge sits at fy + 11, horizontally centered at fx + fw/2.
|
|
11
|
+
* - target = the member card's top-edge midpoint {nx, ny - NODE_H/2}.
|
|
12
|
+
*
|
|
13
|
+
* Cross-team members draw solid from their first membership (state order)
|
|
14
|
+
* and dashed from every later one - the many-to-many invariant made
|
|
15
|
+
* visible. Edges are derived only; there is no freehand edge in this
|
|
16
|
+
* canvas. Pure: no DOM, no three.
|
|
17
|
+
* @module nexus-stage/edges
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { NODE_H } from './world'
|
|
21
|
+
import type { FrameGeom } from './world'
|
|
22
|
+
|
|
23
|
+
/** Titlebar border-box height (planning.css .frame-head must match). */
|
|
24
|
+
export const FRAME_HEAD_H = 24
|
|
25
|
+
/** Titlebar CSS top offset, negative - straddles the frame's top border. */
|
|
26
|
+
export const FRAME_HEAD_TOP = -13
|
|
27
|
+
/** Origin anchor: frame top + the head's bottom edge offset. */
|
|
28
|
+
export const FRAME_HEAD_LIFT = FRAME_HEAD_TOP + FRAME_HEAD_H
|
|
29
|
+
|
|
30
|
+
/** Minimal team shape (topology.CanvasTeam satisfies it). */
|
|
31
|
+
export interface EdgeTeam { name: string; members: ReadonlyArray<{ id: string }> }
|
|
32
|
+
|
|
33
|
+
/** One derived spoke, ready for the SVG layer. */
|
|
34
|
+
export interface StarEdge {
|
|
35
|
+
x1: number
|
|
36
|
+
y1: number
|
|
37
|
+
x2: number
|
|
38
|
+
y2: number
|
|
39
|
+
/** True from a member's second membership on (cross-team many-to-many). */
|
|
40
|
+
dashed: boolean
|
|
41
|
+
/** Team name and member id, for keyed diffing. */
|
|
42
|
+
team: string
|
|
43
|
+
id: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute every membership spoke for the current world. Frames without a
|
|
48
|
+
* rect and member ids without a position are skipped - dangling rows are
|
|
49
|
+
* a polling transient, not an error.
|
|
50
|
+
*/
|
|
51
|
+
export function starEdges(
|
|
52
|
+
teams: ReadonlyArray<EdgeTeam>,
|
|
53
|
+
frames: ReadonlyMap<string, FrameGeom>,
|
|
54
|
+
positions: ReadonlyMap<string, { readonly x: number; readonly y: number }>,
|
|
55
|
+
): StarEdge[] {
|
|
56
|
+
const out: StarEdge[] = []
|
|
57
|
+
const seen = new Set<string>()
|
|
58
|
+
for (const team of teams) {
|
|
59
|
+
const rect = frames.get(team.name)
|
|
60
|
+
if (rect === undefined) continue
|
|
61
|
+
const x1 = rect.x + rect.w / 2
|
|
62
|
+
const y1 = rect.y + FRAME_HEAD_LIFT
|
|
63
|
+
for (const member of team.members) {
|
|
64
|
+
const pos = positions.get(member.id)
|
|
65
|
+
if (pos === undefined) continue
|
|
66
|
+
const dashed = seen.has(member.id)
|
|
67
|
+
seen.add(member.id)
|
|
68
|
+
out.push({ x1, y1, x2: pos.x, y2: pos.y - NODE_H / 2, dashed, team: team.name, id: member.id })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
@@ -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/seat.ts
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic fallback seating: a session id hashes to a stable polar
|
|
3
|
-
* seat, so a reload reproduces the same star map even when no layout is
|
|
4
|
-
* persisted (saved layouts still win - main.ts checks them first). This
|
|
5
|
-
* replaces three Math.random() calls whose only product was a star map
|
|
6
|
-
* that reshuffled on every reload, making screenshots and sessions
|
|
7
|
-
* incomparable. Same hash family as getHue (FNV-1a 32-bit); zero
|
|
8
|
-
* dependencies so the unit test pins stability/bounds without three.
|
|
9
|
-
* @module nexus-stage/seat
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** Radial distance bounds, mirroring the historical random-seat envelope. */
|
|
13
|
-
export const SEAT_DIST_MIN = 12
|
|
14
|
-
export const SEAT_DIST_MAX = 30
|
|
15
|
-
/** Vertical jitter bound in world units above/below the grid plane. */
|
|
16
|
-
export const SEAT_Y_SPAN = 1
|
|
17
|
-
|
|
18
|
-
/** FNV-1a 32-bit - the same determinism family as getHue. */
|
|
19
|
-
export function hashId(sid: string): number {
|
|
20
|
-
let h = 0x811c9dc5
|
|
21
|
-
for (let i = 0; i < sid.length; i++) {
|
|
22
|
-
h ^= sid.charCodeAt(i)
|
|
23
|
-
h = Math.imul(h, 0x01000193) >>> 0
|
|
24
|
-
}
|
|
25
|
-
return h >>> 0
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Deterministic world seat (x, vertical jitter, z) for one session id. */
|
|
29
|
-
export function seatFor(sid: string): { x: number; y: number; z: number } {
|
|
30
|
-
const h = hashId(sid)
|
|
31
|
-
const angle = ((h % 3600) / 3600) * Math.PI * 2
|
|
32
|
-
const dist = SEAT_DIST_MIN + (((h >>> 9) % 1800) / 1800) * (SEAT_DIST_MAX - SEAT_DIST_MIN)
|
|
33
|
-
const y = (((h >>> 18) % 2000) / 1999) * 2 * SEAT_Y_SPAN - SEAT_Y_SPAN
|
|
34
|
-
return { x: Math.cos(angle) * dist, y, z: Math.sin(angle) * dist }
|
|
35
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic fallback seating: a session id hashes to a stable polar
|
|
3
|
+
* seat, so a reload reproduces the same star map even when no layout is
|
|
4
|
+
* persisted (saved layouts still win - main.ts checks them first). This
|
|
5
|
+
* replaces three Math.random() calls whose only product was a star map
|
|
6
|
+
* that reshuffled on every reload, making screenshots and sessions
|
|
7
|
+
* incomparable. Same hash family as getHue (FNV-1a 32-bit); zero
|
|
8
|
+
* dependencies so the unit test pins stability/bounds without three.
|
|
9
|
+
* @module nexus-stage/seat
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Radial distance bounds, mirroring the historical random-seat envelope. */
|
|
13
|
+
export const SEAT_DIST_MIN = 12
|
|
14
|
+
export const SEAT_DIST_MAX = 30
|
|
15
|
+
/** Vertical jitter bound in world units above/below the grid plane. */
|
|
16
|
+
export const SEAT_Y_SPAN = 1
|
|
17
|
+
|
|
18
|
+
/** FNV-1a 32-bit - the same determinism family as getHue. */
|
|
19
|
+
export function hashId(sid: string): number {
|
|
20
|
+
let h = 0x811c9dc5
|
|
21
|
+
for (let i = 0; i < sid.length; i++) {
|
|
22
|
+
h ^= sid.charCodeAt(i)
|
|
23
|
+
h = Math.imul(h, 0x01000193) >>> 0
|
|
24
|
+
}
|
|
25
|
+
return h >>> 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Deterministic world seat (x, vertical jitter, z) for one session id. */
|
|
29
|
+
export function seatFor(sid: string): { x: number; y: number; z: number } {
|
|
30
|
+
const h = hashId(sid)
|
|
31
|
+
const angle = ((h % 3600) / 3600) * Math.PI * 2
|
|
32
|
+
const dist = SEAT_DIST_MIN + (((h >>> 9) % 1800) / 1800) * (SEAT_DIST_MAX - SEAT_DIST_MIN)
|
|
33
|
+
const y = (((h >>> 18) % 2000) / 1999) * 2 * SEAT_Y_SPAN - SEAT_Y_SPAN
|
|
34
|
+
return { x: Math.cos(angle) * dist, y, z: Math.sin(angle) * dist }
|
|
35
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Display name for one stage node: the session title when present, then the
|
|
3
|
-
* routable team label (`team/<id8>`), then the host fallback label
|
|
4
|
-
* (`dsh-host-<node>-<id8>`). Untitled cold joined sessions carry no title and
|
|
5
|
-
* without the middle step the canvas drowned in dsh-host-* noise (2026-08-30
|
|
6
|
-
* user report: the planning canvas showed a dozen unreadable label cards).
|
|
7
|
-
* @module nexus-stage/stage-name
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** One node's display title. */
|
|
11
|
-
export function displayName(node: { readonly name?: string; readonly team?: string; readonly label?: string }): string {
|
|
12
|
-
return node.name ?? node.team ?? node.label ?? ''
|
|
13
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Display name for one stage node: the session title when present, then the
|
|
3
|
+
* routable team label (`team/<id8>`), then the host fallback label
|
|
4
|
+
* (`dsh-host-<node>-<id8>`). Untitled cold joined sessions carry no title and
|
|
5
|
+
* without the middle step the canvas drowned in dsh-host-* noise (2026-08-30
|
|
6
|
+
* user report: the planning canvas showed a dozen unreadable label cards).
|
|
7
|
+
* @module nexus-stage/stage-name
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** One node's display title. */
|
|
11
|
+
export function displayName(node: { readonly name?: string; readonly team?: string; readonly label?: string }): string {
|
|
12
|
+
return node.name ?? node.team ?? node.label ?? ''
|
|
13
|
+
}
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A2A Nexus design tokens.
|
|
3
|
-
* Three-layer: primitive(C) -> semantic(S) -> component-level inline styles.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export const C = {
|
|
7
|
-
bg0: '#060a12',
|
|
8
|
-
bg1: '#0d1322',
|
|
9
|
-
ink: '#dde3f0',
|
|
10
|
-
inkMuted: '#7a86a0',
|
|
11
|
-
accent: '#8b5cf6',
|
|
12
|
-
liveGreen: '#4ade80',
|
|
13
|
-
coldGrey: '#4a5568',
|
|
14
|
-
lineFederal: '#6366f1',
|
|
15
|
-
membershipLine: '#22d3ee',
|
|
16
|
-
} as const;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Semantic layer: the scene-facing names the viewer actually consumes,
|
|
20
|
-
* each pinned to its primitive above. This is the `S` main.ts reached for
|
|
21
|
-
* as `T.S.*` — the layer was documented but never exported, so every seat
|
|
22
|
-
* attempt threw and cycle()'s empty catch buried it: an eternally empty
|
|
23
|
-
* stage behind a healthy-looking render loop.
|
|
24
|
-
*/
|
|
25
|
-
export const S = {
|
|
26
|
-
/** Mesh color + emissive for live session nodes (primitive liveGreen). */
|
|
27
|
-
nodeLive: 0x4ade80,
|
|
28
|
-
/** Mesh color + emissive for cold/joined-offline nodes (primitive coldGrey). */
|
|
29
|
-
nodeCold: 0x4a5568,
|
|
30
|
-
} as const;
|
|
31
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A2A Nexus design tokens.
|
|
3
|
+
* Three-layer: primitive(C) -> semantic(S) -> component-level inline styles.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const C = {
|
|
7
|
+
bg0: '#060a12',
|
|
8
|
+
bg1: '#0d1322',
|
|
9
|
+
ink: '#dde3f0',
|
|
10
|
+
inkMuted: '#7a86a0',
|
|
11
|
+
accent: '#8b5cf6',
|
|
12
|
+
liveGreen: '#4ade80',
|
|
13
|
+
coldGrey: '#4a5568',
|
|
14
|
+
lineFederal: '#6366f1',
|
|
15
|
+
membershipLine: '#22d3ee',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Semantic layer: the scene-facing names the viewer actually consumes,
|
|
20
|
+
* each pinned to its primitive above. This is the `S` main.ts reached for
|
|
21
|
+
* as `T.S.*` — the layer was documented but never exported, so every seat
|
|
22
|
+
* attempt threw and cycle()'s empty catch buried it: an eternally empty
|
|
23
|
+
* stage behind a healthy-looking render loop.
|
|
24
|
+
*/
|
|
25
|
+
export const S = {
|
|
26
|
+
/** Mesh color + emissive for live session nodes (primitive liveGreen). */
|
|
27
|
+
nodeLive: 0x4ade80,
|
|
28
|
+
/** Mesh color + emissive for cold/joined-offline nodes (primitive coldGrey). */
|
|
29
|
+
nodeCold: 0x4a5568,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
32
|
export const FRAME_HUES = [0x8b5cf6, 0x3b82f6, 0x14b8a6, 0xf59e0b, 0xec4899, 0x84cc16];
|