@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README_zh.md +1 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/index.js +57 -9
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- package/lib/types/origin-claim.d.ts +2 -0
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +513 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1272 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
|
@@ -1,164 +1,164 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The canvas write face's transport layer (POST /__dsh_a2a/canvas): action
|
|
3
|
-
* sequencing, per-team serialization, and the notice classification that
|
|
4
|
-
* design.md §3.4 mandates (host error text surfaces VERBATIM - the sidebar
|
|
5
|
-
* reference's swallowed `.catch(() => {})` is the anti-pattern here).
|
|
6
|
-
*
|
|
7
|
-
* Classification per request:
|
|
8
|
-
* - `ok:true` → success (silent)
|
|
9
|
-
* - `ok:false` WITH an `error` string → error notice, verbatim
|
|
10
|
-
* - `ok:false` without `error` → success only when returned members/teams
|
|
11
|
-
* prove the requested idempotent effect; cap/unknown results are failures
|
|
12
|
-
* - HTTP >= 400 → body error verbatim, else `HTTP <status>`
|
|
13
|
-
* - send() throws → `画布不可达:<message>`
|
|
14
|
-
* - missing success acknowledgement (including non-JSON transport errors)
|
|
15
|
-
* → visible failure, never a confirmed join
|
|
16
|
-
*
|
|
17
|
-
* Ops on the same team run through a serial queue (a later gesture waits
|
|
18
|
-
* instead of racing; cross-team ops run in parallel) - mirrors the
|
|
19
|
-
* sidebar's per-team busy keys, minus the rejection hostility. Pure
|
|
20
|
-
* control flow: the transport is injected.
|
|
21
|
-
* @module nexus-stage/canvas-wire
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** One send outcome: HTTP status plus the parsed JSON body (whatever it is). */
|
|
25
|
-
export interface CanvasWireResult {
|
|
26
|
-
status: number
|
|
27
|
-
body: { ok?: boolean; error?: string; teams?: readonly string[]; members?: readonly string[]; name?: string }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Transport injection: POST the body, resolve with parsed outcome, throw on network failure. */
|
|
31
|
-
export type CanvasSend = (body: unknown) => Promise<CanvasWireResult>
|
|
32
|
-
|
|
33
|
-
export interface CanvasWireDeps {
|
|
34
|
-
send: CanvasSend
|
|
35
|
-
onNotice(kind: 'error' | 'info', text: string): void
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface CanvasWire {
|
|
39
|
-
/** create → add-member per id, serial, first failure stops. `created` is
|
|
40
|
-
* a model-side hint ONLY, never server creation provenance. A failed add
|
|
41
|
-
* cannot authorize deleting a same-name team; preserve it for retry. */
|
|
42
|
-
createTeam(name: string, ids: readonly string[], created?: boolean): Promise<boolean>
|
|
43
|
-
addMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
44
|
-
removeMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
45
|
-
/** Pre-computed remove/add roster ops (from canvas-ops.reorderOps). */
|
|
46
|
-
runRosterOps(team: string, ops: ReadonlyArray<{ op: 'remove' | 'add'; id: string }>): Promise<boolean>
|
|
47
|
-
removeTeam(name: string): Promise<boolean>
|
|
48
|
-
hasPending(): boolean
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
52
|
-
|
|
53
|
-
function errorText(body: CanvasWireResult['body']): string | undefined {
|
|
54
|
-
return body != null && typeof body.error === 'string' ? body.error : undefined
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function createCanvasWire(deps: CanvasWireDeps): CanvasWire {
|
|
58
|
-
const queues = new Map<string, Promise<unknown>>()
|
|
59
|
-
let pending = 0
|
|
60
|
-
|
|
61
|
-
/** Serial per-team queue: same key waits, other keys run in parallel. */
|
|
62
|
-
function enqueue<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
63
|
-
pending += 1
|
|
64
|
-
const prev = queues.get(key) ?? Promise.resolve()
|
|
65
|
-
const run = async (): Promise<T> => {
|
|
66
|
-
try {
|
|
67
|
-
return await task()
|
|
68
|
-
} finally {
|
|
69
|
-
pending -= 1 // settles BEFORE the returned promise resolves
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
const next = prev.then(run, run) // run regardless of how the predecessor settled
|
|
73
|
-
queues.set(
|
|
74
|
-
key,
|
|
75
|
-
next.then(
|
|
76
|
-
() => undefined,
|
|
77
|
-
() => undefined,
|
|
78
|
-
),
|
|
79
|
-
)
|
|
80
|
-
return next
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** One classified request — NEVER enqueues (public ops own the queue). */
|
|
84
|
-
async function raw(body: Record<string, unknown>): Promise<boolean> {
|
|
85
|
-
let res: CanvasWireResult
|
|
86
|
-
try {
|
|
87
|
-
res = await deps.send(body)
|
|
88
|
-
} catch (error) {
|
|
89
|
-
const message = String((error as Error | undefined)?.message ?? error).slice(0, 80)
|
|
90
|
-
deps.onNotice('error', `画布不可达:${message}`)
|
|
91
|
-
return false
|
|
92
|
-
}
|
|
93
|
-
if (res.status >= 400) {
|
|
94
|
-
deps.onNotice('error', errorText(res.body) ?? `HTTP ${res.status}`)
|
|
95
|
-
return false
|
|
96
|
-
}
|
|
97
|
-
if (res.body?.ok === true) return true
|
|
98
|
-
const text = res.body != null && typeof res.body === 'object' ? errorText(res.body) : undefined
|
|
99
|
-
if (text !== undefined) {
|
|
100
|
-
deps.onNotice('error', text)
|
|
101
|
-
return false
|
|
102
|
-
}
|
|
103
|
-
// A bare false can mean a capacity refusal, not just an idempotent no-op.
|
|
104
|
-
// Accept a no-op only when the host's returned state proves the effect.
|
|
105
|
-
const name = String(body.name ?? ''), id = String(body.id ?? '')
|
|
106
|
-
const members = res.body?.members, teams = res.body?.teams
|
|
107
|
-
const memberProof = Array.isArray(members) && members.every(v => typeof v === 'string')
|
|
108
|
-
const teamProof = Array.isArray(teams) && teams.every(v => typeof v === 'string')
|
|
109
|
-
if (res.body?.ok === false && (
|
|
110
|
-
(body.action === 'add-member' && memberProof && members.includes(id)) ||
|
|
111
|
-
(body.action === 'remove-member' && memberProof && !members.includes(id)) ||
|
|
112
|
-
(body.action === 'remove' && teamProof && !teams.includes(name))
|
|
113
|
-
)) return true
|
|
114
|
-
deps.onNotice('error', '画布操作未确认:' + String(body.action) + '(可能队伍/成员已达上限,请重试)')
|
|
115
|
-
return false
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function serial(body: Record<string, unknown>, ids: readonly string[]): Promise<boolean> {
|
|
119
|
-
for (const id of ids) {
|
|
120
|
-
if (!(await raw({ ...body, id }))) return false
|
|
121
|
-
}
|
|
122
|
-
return true
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
createTeam(name, ids) {
|
|
127
|
-
return enqueue(name, async () => {
|
|
128
|
-
if (!(await raw({ action: 'create', name }))) return false
|
|
129
|
-
for (let i = 0; i < ids.length; i++) {
|
|
130
|
-
if (!(await raw({ action: 'add-member', name, id: ids[i]! }))) {
|
|
131
|
-
// Local absence does not prove server-side creation. Never issue
|
|
132
|
-
// an unconditional compensating remove: an idempotent create may
|
|
133
|
-
// have reused an existing team (including one already at capacity).
|
|
134
|
-
return false
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return true
|
|
138
|
-
})
|
|
139
|
-
},
|
|
140
|
-
addMembers(team, ids) {
|
|
141
|
-
return enqueue(team, () => serial({ action: 'add-member', name: team }, ids))
|
|
142
|
-
},
|
|
143
|
-
removeMembers(team, ids) {
|
|
144
|
-
return enqueue(team, () => serial({ action: 'remove-member', name: team }, ids))
|
|
145
|
-
},
|
|
146
|
-
runRosterOps(team, ops) {
|
|
147
|
-
return enqueue(team, async () => {
|
|
148
|
-
for (const op of ops) {
|
|
149
|
-
const body = op.op === 'remove'
|
|
150
|
-
? { action: 'remove-member', name: team, id: op.id }
|
|
151
|
-
: { action: 'add-member', name: team, id: op.id }
|
|
152
|
-
if (!(await raw(body))) return false
|
|
153
|
-
}
|
|
154
|
-
return true
|
|
155
|
-
})
|
|
156
|
-
},
|
|
157
|
-
removeTeam(name) {
|
|
158
|
-
return enqueue(name, () => raw({ action: 'remove', name }))
|
|
159
|
-
},
|
|
160
|
-
hasPending() {
|
|
161
|
-
return pending > 0
|
|
162
|
-
},
|
|
163
|
-
}
|
|
164
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The canvas write face's transport layer (POST /__dsh_a2a/canvas): action
|
|
3
|
+
* sequencing, per-team serialization, and the notice classification that
|
|
4
|
+
* design.md §3.4 mandates (host error text surfaces VERBATIM - the sidebar
|
|
5
|
+
* reference's swallowed `.catch(() => {})` is the anti-pattern here).
|
|
6
|
+
*
|
|
7
|
+
* Classification per request:
|
|
8
|
+
* - `ok:true` → success (silent)
|
|
9
|
+
* - `ok:false` WITH an `error` string → error notice, verbatim
|
|
10
|
+
* - `ok:false` without `error` → success only when returned members/teams
|
|
11
|
+
* prove the requested idempotent effect; cap/unknown results are failures
|
|
12
|
+
* - HTTP >= 400 → body error verbatim, else `HTTP <status>`
|
|
13
|
+
* - send() throws → `画布不可达:<message>`
|
|
14
|
+
* - missing success acknowledgement (including non-JSON transport errors)
|
|
15
|
+
* → visible failure, never a confirmed join
|
|
16
|
+
*
|
|
17
|
+
* Ops on the same team run through a serial queue (a later gesture waits
|
|
18
|
+
* instead of racing; cross-team ops run in parallel) - mirrors the
|
|
19
|
+
* sidebar's per-team busy keys, minus the rejection hostility. Pure
|
|
20
|
+
* control flow: the transport is injected.
|
|
21
|
+
* @module nexus-stage/canvas-wire
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One send outcome: HTTP status plus the parsed JSON body (whatever it is). */
|
|
25
|
+
export interface CanvasWireResult {
|
|
26
|
+
status: number
|
|
27
|
+
body: { ok?: boolean; error?: string; teams?: readonly string[]; members?: readonly string[]; name?: string }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Transport injection: POST the body, resolve with parsed outcome, throw on network failure. */
|
|
31
|
+
export type CanvasSend = (body: unknown) => Promise<CanvasWireResult>
|
|
32
|
+
|
|
33
|
+
export interface CanvasWireDeps {
|
|
34
|
+
send: CanvasSend
|
|
35
|
+
onNotice(kind: 'error' | 'info', text: string): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CanvasWire {
|
|
39
|
+
/** create → add-member per id, serial, first failure stops. `created` is
|
|
40
|
+
* a model-side hint ONLY, never server creation provenance. A failed add
|
|
41
|
+
* cannot authorize deleting a same-name team; preserve it for retry. */
|
|
42
|
+
createTeam(name: string, ids: readonly string[], created?: boolean): Promise<boolean>
|
|
43
|
+
addMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
44
|
+
removeMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
45
|
+
/** Pre-computed remove/add roster ops (from canvas-ops.reorderOps). */
|
|
46
|
+
runRosterOps(team: string, ops: ReadonlyArray<{ op: 'remove' | 'add'; id: string }>): Promise<boolean>
|
|
47
|
+
removeTeam(name: string): Promise<boolean>
|
|
48
|
+
hasPending(): boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
52
|
+
|
|
53
|
+
function errorText(body: CanvasWireResult['body']): string | undefined {
|
|
54
|
+
return body != null && typeof body.error === 'string' ? body.error : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createCanvasWire(deps: CanvasWireDeps): CanvasWire {
|
|
58
|
+
const queues = new Map<string, Promise<unknown>>()
|
|
59
|
+
let pending = 0
|
|
60
|
+
|
|
61
|
+
/** Serial per-team queue: same key waits, other keys run in parallel. */
|
|
62
|
+
function enqueue<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
63
|
+
pending += 1
|
|
64
|
+
const prev = queues.get(key) ?? Promise.resolve()
|
|
65
|
+
const run = async (): Promise<T> => {
|
|
66
|
+
try {
|
|
67
|
+
return await task()
|
|
68
|
+
} finally {
|
|
69
|
+
pending -= 1 // settles BEFORE the returned promise resolves
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const next = prev.then(run, run) // run regardless of how the predecessor settled
|
|
73
|
+
queues.set(
|
|
74
|
+
key,
|
|
75
|
+
next.then(
|
|
76
|
+
() => undefined,
|
|
77
|
+
() => undefined,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
return next
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One classified request — NEVER enqueues (public ops own the queue). */
|
|
84
|
+
async function raw(body: Record<string, unknown>): Promise<boolean> {
|
|
85
|
+
let res: CanvasWireResult
|
|
86
|
+
try {
|
|
87
|
+
res = await deps.send(body)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message = String((error as Error | undefined)?.message ?? error).slice(0, 80)
|
|
90
|
+
deps.onNotice('error', `画布不可达:${message}`)
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
if (res.status >= 400) {
|
|
94
|
+
deps.onNotice('error', errorText(res.body) ?? `HTTP ${res.status}`)
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
if (res.body?.ok === true) return true
|
|
98
|
+
const text = res.body != null && typeof res.body === 'object' ? errorText(res.body) : undefined
|
|
99
|
+
if (text !== undefined) {
|
|
100
|
+
deps.onNotice('error', text)
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
// A bare false can mean a capacity refusal, not just an idempotent no-op.
|
|
104
|
+
// Accept a no-op only when the host's returned state proves the effect.
|
|
105
|
+
const name = String(body.name ?? ''), id = String(body.id ?? '')
|
|
106
|
+
const members = res.body?.members, teams = res.body?.teams
|
|
107
|
+
const memberProof = Array.isArray(members) && members.every(v => typeof v === 'string')
|
|
108
|
+
const teamProof = Array.isArray(teams) && teams.every(v => typeof v === 'string')
|
|
109
|
+
if (res.body?.ok === false && (
|
|
110
|
+
(body.action === 'add-member' && memberProof && members.includes(id)) ||
|
|
111
|
+
(body.action === 'remove-member' && memberProof && !members.includes(id)) ||
|
|
112
|
+
(body.action === 'remove' && teamProof && !teams.includes(name))
|
|
113
|
+
)) return true
|
|
114
|
+
deps.onNotice('error', '画布操作未确认:' + String(body.action) + '(可能队伍/成员已达上限,请重试)')
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function serial(body: Record<string, unknown>, ids: readonly string[]): Promise<boolean> {
|
|
119
|
+
for (const id of ids) {
|
|
120
|
+
if (!(await raw({ ...body, id }))) return false
|
|
121
|
+
}
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
createTeam(name, ids) {
|
|
127
|
+
return enqueue(name, async () => {
|
|
128
|
+
if (!(await raw({ action: 'create', name }))) return false
|
|
129
|
+
for (let i = 0; i < ids.length; i++) {
|
|
130
|
+
if (!(await raw({ action: 'add-member', name, id: ids[i]! }))) {
|
|
131
|
+
// Local absence does not prove server-side creation. Never issue
|
|
132
|
+
// an unconditional compensating remove: an idempotent create may
|
|
133
|
+
// have reused an existing team (including one already at capacity).
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return true
|
|
138
|
+
})
|
|
139
|
+
},
|
|
140
|
+
addMembers(team, ids) {
|
|
141
|
+
return enqueue(team, () => serial({ action: 'add-member', name: team }, ids))
|
|
142
|
+
},
|
|
143
|
+
removeMembers(team, ids) {
|
|
144
|
+
return enqueue(team, () => serial({ action: 'remove-member', name: team }, ids))
|
|
145
|
+
},
|
|
146
|
+
runRosterOps(team, ops) {
|
|
147
|
+
return enqueue(team, async () => {
|
|
148
|
+
for (const op of ops) {
|
|
149
|
+
const body = op.op === 'remove'
|
|
150
|
+
? { action: 'remove-member', name: team, id: op.id }
|
|
151
|
+
: { action: 'add-member', name: team, id: op.id }
|
|
152
|
+
if (!(await raw(body))) return false
|
|
153
|
+
}
|
|
154
|
+
return true
|
|
155
|
+
})
|
|
156
|
+
},
|
|
157
|
+
removeTeam(name) {
|
|
158
|
+
return enqueue(name, () => raw({ action: 'remove', name }))
|
|
159
|
+
},
|
|
160
|
+
hasPending() {
|
|
161
|
+
return pending > 0
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
/** Explicit operator choice only; this form never auto-selects or relaxes home admission. */
|
|
2
|
-
export interface CliHomeOptions { ok: boolean; homes?: string[]; unreadable?: number; held?: boolean; error?: string }
|
|
3
|
-
export function createCliHomePicker(read: () => Promise<CliHomeOptions>, save: (home: string) => Promise<boolean>): HTMLFormElement {
|
|
4
|
-
const form = document.createElement('form')
|
|
5
|
-
form.className = 'wb-cli-home'
|
|
6
|
-
const readButton = document.createElement('button')
|
|
7
|
-
readButton.type = 'button'; readButton.textContent = '指定 home'; readButton.className = 'wb-member-submit'
|
|
8
|
-
const select = document.createElement('select')
|
|
9
|
-
select.className = 'wb-member-pick'; select.setAttribute('aria-label', '选择 CLI home 主机'); select.hidden = true
|
|
10
|
-
const submit = document.createElement('button')
|
|
11
|
-
submit.type = 'submit'; submit.textContent = '确认 home'; submit.className = 'wb-member-submit'; submit.hidden = true; submit.disabled = true
|
|
12
|
-
const note = document.createElement('div')
|
|
13
|
-
note.className = 'wb-hint'; note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite')
|
|
14
|
-
note.textContent = 'home 未确定;点击指定 home 现读已链接主机名片。'
|
|
15
|
-
let busy = false
|
|
16
|
-
readButton.addEventListener('click', async () => {
|
|
17
|
-
if (busy) return
|
|
18
|
-
busy = true; readButton.disabled = true; submit.disabled = true; note.textContent = '正在现读主机名片…'
|
|
19
|
-
try {
|
|
20
|
-
const result = await read()
|
|
21
|
-
if (!result.ok) throw Error(result.error ?? '候选读取失败')
|
|
22
|
-
if (result.held) { readButton.hidden = true; select.hidden = true; submit.hidden = true; select.replaceChildren(); note.textContent = '本机持有,无需指定'; return }
|
|
23
|
-
select.replaceChildren(new Option('请选择 home 主机…', '', true, true))
|
|
24
|
-
for (const home of result.homes ?? []) select.appendChild(new Option(home, home))
|
|
25
|
-
select.hidden = false; submit.hidden = false; readButton.textContent = '刷新候选'
|
|
26
|
-
const count = result.homes?.length ?? 0
|
|
27
|
-
note.textContent = (result.unreadable ? '有 ' + result.unreadable + ' 台对端名片读不到;' : '') + (count > 1 ? '多台已链接主机列出该句柄,请显式选择。' : count === 1 ? '找到 1 台已链接主机;仍需你显式确认。' : '当前没有已链接且列出该句柄的可选主机。')
|
|
28
|
-
} catch (error) { note.textContent = '读取失败:' + String(error); select.hidden = true; submit.hidden = true }
|
|
29
|
-
finally { busy = false; readButton.disabled = false }
|
|
30
|
-
})
|
|
31
|
-
select.addEventListener('change', () => { submit.disabled = busy || select.value === '' })
|
|
32
|
-
form.addEventListener('submit', async event => {
|
|
33
|
-
event.preventDefault()
|
|
34
|
-
if (busy || select.value === '') return
|
|
35
|
-
busy = true; readButton.disabled = true; select.disabled = true; submit.disabled = true
|
|
36
|
-
try { note.textContent = await save(select.value) ? 'home 已指定:' + select.value : '指定失败:主机可能已离线或不再列出该句柄,请刷新后重选。' }
|
|
37
|
-
catch (error) { note.textContent = '指定失败:' + String(error) }
|
|
38
|
-
finally { busy = false; readButton.disabled = false; select.disabled = false; submit.disabled = select.value === '' }
|
|
39
|
-
})
|
|
40
|
-
form.append(readButton, select, submit, note)
|
|
41
|
-
return form
|
|
42
|
-
}
|
|
1
|
+
/** Explicit operator choice only; this form never auto-selects or relaxes home admission. */
|
|
2
|
+
export interface CliHomeOptions { ok: boolean; homes?: string[]; unreadable?: number; held?: boolean; error?: string }
|
|
3
|
+
export function createCliHomePicker(read: () => Promise<CliHomeOptions>, save: (home: string) => Promise<boolean>): HTMLFormElement {
|
|
4
|
+
const form = document.createElement('form')
|
|
5
|
+
form.className = 'wb-cli-home'
|
|
6
|
+
const readButton = document.createElement('button')
|
|
7
|
+
readButton.type = 'button'; readButton.textContent = '指定 home'; readButton.className = 'wb-member-submit'
|
|
8
|
+
const select = document.createElement('select')
|
|
9
|
+
select.className = 'wb-member-pick'; select.setAttribute('aria-label', '选择 CLI home 主机'); select.hidden = true
|
|
10
|
+
const submit = document.createElement('button')
|
|
11
|
+
submit.type = 'submit'; submit.textContent = '确认 home'; submit.className = 'wb-member-submit'; submit.hidden = true; submit.disabled = true
|
|
12
|
+
const note = document.createElement('div')
|
|
13
|
+
note.className = 'wb-hint'; note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite')
|
|
14
|
+
note.textContent = 'home 未确定;点击指定 home 现读已链接主机名片。'
|
|
15
|
+
let busy = false
|
|
16
|
+
readButton.addEventListener('click', async () => {
|
|
17
|
+
if (busy) return
|
|
18
|
+
busy = true; readButton.disabled = true; submit.disabled = true; note.textContent = '正在现读主机名片…'
|
|
19
|
+
try {
|
|
20
|
+
const result = await read()
|
|
21
|
+
if (!result.ok) throw Error(result.error ?? '候选读取失败')
|
|
22
|
+
if (result.held) { readButton.hidden = true; select.hidden = true; submit.hidden = true; select.replaceChildren(); note.textContent = '本机持有,无需指定'; return }
|
|
23
|
+
select.replaceChildren(new Option('请选择 home 主机…', '', true, true))
|
|
24
|
+
for (const home of result.homes ?? []) select.appendChild(new Option(home, home))
|
|
25
|
+
select.hidden = false; submit.hidden = false; readButton.textContent = '刷新候选'
|
|
26
|
+
const count = result.homes?.length ?? 0
|
|
27
|
+
note.textContent = (result.unreadable ? '有 ' + result.unreadable + ' 台对端名片读不到;' : '') + (count > 1 ? '多台已链接主机列出该句柄,请显式选择。' : count === 1 ? '找到 1 台已链接主机;仍需你显式确认。' : '当前没有已链接且列出该句柄的可选主机。')
|
|
28
|
+
} catch (error) { note.textContent = '读取失败:' + String(error); select.hidden = true; submit.hidden = true }
|
|
29
|
+
finally { busy = false; readButton.disabled = false }
|
|
30
|
+
})
|
|
31
|
+
select.addEventListener('change', () => { submit.disabled = busy || select.value === '' })
|
|
32
|
+
form.addEventListener('submit', async event => {
|
|
33
|
+
event.preventDefault()
|
|
34
|
+
if (busy || select.value === '') return
|
|
35
|
+
busy = true; readButton.disabled = true; select.disabled = true; submit.disabled = true
|
|
36
|
+
try { note.textContent = await save(select.value) ? 'home 已指定:' + select.value : '指定失败:主机可能已离线或不再列出该句柄,请刷新后重选。' }
|
|
37
|
+
catch (error) { note.textContent = '指定失败:' + String(error) }
|
|
38
|
+
finally { busy = false; readButton.disabled = false; select.disabled = false; submit.disabled = select.value === '' }
|
|
39
|
+
})
|
|
40
|
+
form.append(readButton, select, submit, note)
|
|
41
|
+
return form
|
|
42
|
+
}
|
package/nexus-stage/src/fault.ts
CHANGED
|
@@ -1,56 +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
|
-
}
|
|
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
|
+
}
|