@nelsonlongxiang/dsh-open-a2a-net 0.6.32 → 0.6.33
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 +7 -1
- package/README_zh.md +7 -1
- package/assets/nexusDist/assets/index-BACVZe0j.css +1 -0
- package/assets/nexusDist/assets/index-Bbxnpf2p.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +5 -2
- package/lib/canvas-store.js +6 -0
- package/lib/client.js +63 -63
- package/lib/faces/admin.js +43 -4
- package/lib/faces/wire.js +8 -6
- package/lib/index.js +11 -6
- package/lib/layout-store.js +3 -2
- package/lib/roster-op.js +104 -0
- package/lib/skill/references/cli.md +1 -1
- package/lib/skill/references/router.md +1 -1
- package/lib/team-store.js +49 -1
- package/lib/types/canvas-store.d.ts +4 -0
- package/lib/types/faces/admin.d.ts +1 -0
- package/lib/types/faces/wire.d.ts +1 -0
- package/lib/types/roster-op.d.ts +21 -0
- package/lib/types/team-store.d.ts +7 -0
- package/nexus-stage/src/canvas-ops.ts +2 -1
- package/nexus-stage/src/layout-doc.ts +6 -4
- package/nexus-stage/src/main.ts +27 -2
- package/nexus-stage/src/planning-frame-hue.ts +13 -0
- package/nexus-stage/src/planning-legend.ts +1 -0
- package/nexus-stage/src/planning-menus.ts +28 -12
- package/nexus-stage/src/planning-roster.ts +15 -0
- package/nexus-stage/src/planning-types.ts +1 -1
- package/nexus-stage/src/planning-view.ts +29 -27
- package/nexus-stage/src/planning.css +2 -0
- package/nexus-stage/src/workbench.ts +2 -0
- package/package.json +3 -2
- package/assets/nexusDist/assets/index-D5H8R8de.js +0 -9
- package/assets/nexusDist/assets/index-D9GYZyGZ.css +0 -1
|
@@ -113,3 +113,7 @@ export declare class CanvasStore {
|
|
|
113
113
|
*/
|
|
114
114
|
dropMember(id: string): void;
|
|
115
115
|
}
|
|
116
|
+
/** One policy predicate for local and signed-remote canvas creation. */
|
|
117
|
+
export declare function canvasNameReserved(raw: string, zone: string, allowlist: readonly string[], roster: {
|
|
118
|
+
membersOf(team: string): readonly string[];
|
|
119
|
+
}): boolean;
|
|
@@ -120,6 +120,7 @@ export interface WireDeps {
|
|
|
120
120
|
readonly config: Config;
|
|
121
121
|
readonly cliDeposits: CliDepositStore;
|
|
122
122
|
readonly canvasStore: CanvasStore;
|
|
123
|
+
readonly canvasNameReserved: (name: string) => boolean;
|
|
123
124
|
readonly sessionNodes: Map<string, Agent>;
|
|
124
125
|
readonly joinedSessions: JoinedSessions;
|
|
125
126
|
readonly idempotencyStore: IdempotencyStore;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type TeamMembershipStore } from './team-store.ts';
|
|
2
|
+
export interface RosterOpDeps {
|
|
3
|
+
zone: string;
|
|
4
|
+
home: string;
|
|
5
|
+
store: TeamMembershipStore;
|
|
6
|
+
joined: (id: string) => boolean;
|
|
7
|
+
canvasExists: (name: string) => boolean;
|
|
8
|
+
allowlist: () => readonly string[];
|
|
9
|
+
setAllowlist: (list: string[]) => void;
|
|
10
|
+
audit: (action: 'join' | 'leave', team: string, id: string, ok: boolean, reason: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export type RosterOpResult = {
|
|
13
|
+
ok: true;
|
|
14
|
+
team: string;
|
|
15
|
+
members: string[];
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
error: string;
|
|
19
|
+
};
|
|
20
|
+
/** Writes complete snapshots; on a failed second write, rolls the first back. */
|
|
21
|
+
export declare function rosterOp(d: RosterOpDeps, raw: unknown): RosterOpResult;
|
|
@@ -51,6 +51,13 @@ export declare class TeamMembershipStore {
|
|
|
51
51
|
add(session: string, team: string): void;
|
|
52
52
|
/** Retract one membership; a no-op when absent. */
|
|
53
53
|
remove(session: string, team: string): void;
|
|
54
|
+
/** One atomic roster replacement for the owner's workbench gesture. A failed disk write changes no in-memory membership. */
|
|
55
|
+
replaceTeam(team: string, members: readonly string[]): {
|
|
56
|
+
ok: true;
|
|
57
|
+
} | {
|
|
58
|
+
ok: false;
|
|
59
|
+
error: string;
|
|
60
|
+
};
|
|
54
61
|
/** Drop every declaration of one session (a left or archived node). */
|
|
55
62
|
dropSession(session: string): void;
|
|
56
63
|
/**
|
|
@@ -19,7 +19,7 @@ import type { LayoutDoc } from './layout-doc'
|
|
|
19
19
|
export type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
20
20
|
|
|
21
21
|
/** The five user-facing write actions (one per design.md §3.3 row), plus the two network join/leave actions the panel-slim migration moved into the planning view. */
|
|
22
|
-
export type CanvasAction =
|
|
22
|
+
export type CanvasAction = (
|
|
23
23
|
| { type: 'create-team'; name: string; ids: readonly string[]; /** true when the model had no such team before this action; NOT server creation provenance and never authority to delete on failure. */ created?: boolean }
|
|
24
24
|
| { type: 'add-member'; team: string; ids: readonly string[]; /** gh#41: set when the target frame is a REMOTE peer's team — the wire routes the op through the host fleet relay. */ fleetPeer?: string }
|
|
25
25
|
| { type: 'remove-member'; team: string; ids: readonly string[]; /** gh#41: see add-member. */ fleetPeer?: string }
|
|
@@ -29,6 +29,7 @@ export type CanvasAction =
|
|
|
29
29
|
/** Network-level join/leave (the session joins the A2A mesh) — distinct from canvas-team membership. */
|
|
30
30
|
| { type: 'join-network'; id: string }
|
|
31
31
|
| { type: 'leave-network'; id: string }
|
|
32
|
+
) & { roster?: boolean }
|
|
32
33
|
|
|
33
34
|
/** Reverts exactly what one applyAction did (team-scoped). */
|
|
34
35
|
export type Undo = () => void
|
|
@@ -66,11 +66,13 @@ function clampRect(raw: unknown): LayoutRect | undefined {
|
|
|
66
66
|
* Normalize an untrusted layout document exactly as the host does:
|
|
67
67
|
* version!==1 rejects the whole doc; non-finite coordinates drop the
|
|
68
68
|
* point; coordinates round and clamp to ±COORD_LIMIT; scale clamps to
|
|
69
|
-
* [SCALE_MIN, SCALE_MAX]; node keys cap at 128 chars; frame
|
|
70
|
-
*
|
|
69
|
+
* [SCALE_MIN, SCALE_MAX]; node keys cap at 128 chars; canvas frame names cap at 40,
|
|
70
|
+
* roster:<name> reserves seven extra characters; rects need w>0 and h>0; caps are
|
|
71
71
|
* first-wins in insertion order - entries beyond are silently dropped,
|
|
72
72
|
* never refused (an overflow renders, it just does not persist).
|
|
73
73
|
*/
|
|
74
|
+
const validFrame = (name: string): boolean => name !== '' && !name.includes('/') && (name.startsWith('roster:') ? name.length > 7 && name.length <= 47 : name.length <= 40)
|
|
75
|
+
|
|
74
76
|
export function clampDoc(raw: unknown): LayoutDoc | undefined {
|
|
75
77
|
if (raw === null || typeof raw !== 'object') return undefined
|
|
76
78
|
const rec = raw as Record<string, unknown>
|
|
@@ -96,7 +98,7 @@ export function clampDoc(raw: unknown): LayoutDoc | undefined {
|
|
|
96
98
|
const frames: Record<string, LayoutRect> = {}
|
|
97
99
|
if (rec.frames !== null && typeof rec.frames === 'object') {
|
|
98
100
|
for (const [name, rect] of Object.entries(rec.frames as Record<string, unknown>)) {
|
|
99
|
-
if (
|
|
101
|
+
if (!validFrame(name)) continue
|
|
100
102
|
const r = clampRect(rect)
|
|
101
103
|
if (r !== undefined) frames[name] = r
|
|
102
104
|
if (Object.keys(frames).length >= LAYOUT_FRAME_CAP) break
|
|
@@ -127,7 +129,7 @@ export function buildLayoutDoc(
|
|
|
127
129
|
}
|
|
128
130
|
const docFrames: Record<string, LayoutRect> = {}
|
|
129
131
|
for (const [name, r] of frames) {
|
|
130
|
-
if (
|
|
132
|
+
if (!validFrame(name)) continue
|
|
131
133
|
const w = clamp(r.w)
|
|
132
134
|
const h = clamp(r.h)
|
|
133
135
|
if (w <= 0 || h <= 0) continue
|
package/nexus-stage/src/main.ts
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
import { createFaultReporter } from './fault'
|
|
10
10
|
import { createWorkbench, type StateBody } from './workbench'
|
|
11
11
|
import { createPlanningView } from './planning-view'
|
|
12
|
+
import { rosterFrames, rosterNameOf } from './planning-roster'
|
|
12
13
|
import { createSaveLoop, type LampState } from './layout-wire'
|
|
13
14
|
import { createCanvasWire } from './canvas-wire'
|
|
14
|
-
import type
|
|
15
|
+
import { actionTeam, type CanvasAction } from './canvas-ops'
|
|
15
16
|
import './workbench.css'
|
|
16
17
|
|
|
17
18
|
const app = document.getElementById('app')!
|
|
@@ -171,6 +172,24 @@ async function fleetOp(peer: string, op: 'add-member' | 'remove-member' | 'remov
|
|
|
171
172
|
return false
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
async function rosterAction(a: CanvasAction, name: string): Promise<boolean> {
|
|
176
|
+
const post = async (body: Record<string, unknown>): Promise<boolean> => {
|
|
177
|
+
const reply = await controlFetch('/__dsh_a2a/roster-op', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ...body }) }).catch(() => undefined)
|
|
178
|
+
const result = reply !== undefined ? await reply.json().catch(() => undefined) as { ok?: boolean; error?: string } | undefined : undefined
|
|
179
|
+
if (reply?.ok && result?.ok === true) return true
|
|
180
|
+
planning.notice('error', `roster 操作失败:${String(result?.error ?? (reply ? `HTTP ${reply.status}` : '无响应'))}`)
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
if (a.type === 'create-team') return post({ op: 'create', ids: a.ids })
|
|
184
|
+
if (a.type === 'remove-team') return post({ op: 'dissolve' })
|
|
185
|
+
if (a.type === 'add-member' || a.type === 'remove-member') {
|
|
186
|
+
for (const id of a.ids) if (!(await post({ op: a.type, id }))) return false
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
planning.notice('error', 'roster 不支持该操作')
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
|
|
174
193
|
function runCanvasAction(a: CanvasAction): Promise<boolean> {
|
|
175
194
|
// gh#41: a remote-frame drop forks to the fleet relay; so does dissolving
|
|
176
195
|
// a mirror frame (single owner + mirror: the owner's store is the truth).
|
|
@@ -182,6 +201,12 @@ function runCanvasAction(a: CanvasAction): Promise<boolean> {
|
|
|
182
201
|
})()
|
|
183
202
|
}
|
|
184
203
|
if (a.type === 'remove-team' && a.fleetPeer !== undefined) return fleetOp(a.fleetPeer, 'remove', a.name)
|
|
204
|
+
// Only a trusted registry roster frame or an explicit create choice may trigger a local roster write.
|
|
205
|
+
if (a.roster === true) {
|
|
206
|
+
const roster = rosterNameOf(actionTeam(a))
|
|
207
|
+
if (roster !== undefined) return rosterAction(a, roster)
|
|
208
|
+
planning.notice('error', '无效的 roster 框名'); return Promise.resolve(false)
|
|
209
|
+
}
|
|
185
210
|
switch (a.type) {
|
|
186
211
|
case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
|
|
187
212
|
case 'add-member': return canvasWire.addMembers(a.team, a.ids)
|
|
@@ -440,7 +465,7 @@ async function poll(): Promise<void> {
|
|
|
440
465
|
// SessionLite needs `joined: boolean` (workbench rows carry `joined?`):
|
|
441
466
|
// tri-state → boolean without losing the unjoined node-list population.
|
|
442
467
|
const allSessions = (body.sessions ?? []).map(s => ({ ...s, joined: s.joined === true }))
|
|
443
|
-
const teams = body.canvas?.teams ?? []
|
|
468
|
+
const teams = [...(body.canvas?.teams ?? []), ...rosterFrames(body.registry?.teams ?? [], body.zone ?? '')]
|
|
444
469
|
const peers = (body.peers ?? []).filter(p => p !== null && typeof p === 'object' && typeof (p as { url?: unknown }).url === 'string')
|
|
445
470
|
const inFlight = (body.inFlight ?? []).filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { peer?: unknown }).peer === 'string' && Number.isFinite((r as { startedAt?: unknown }).startedAt))
|
|
446
471
|
const remoteTeams = (body.remote ?? [])
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Stable team hue shared by canvas and roster frames. */
|
|
2
|
+
import { FRAME_HUES } from './tokens'
|
|
3
|
+
|
|
4
|
+
export function frameHue(name: string): { line: string; bg: string } {
|
|
5
|
+
let h = 0
|
|
6
|
+
for (let i = 0; i < name.length; i++) h = ((h * 31 + name.charCodeAt(i)) & 0xfffffff) >>> 0
|
|
7
|
+
const rgb = FRAME_HUES[h % FRAME_HUES.length]!
|
|
8
|
+
const line = "#" + rgb.toString(16).padStart(6, "0")
|
|
9
|
+
const r = (rgb >> 16) & 0xff
|
|
10
|
+
const g = (rgb >> 8) & 0xff
|
|
11
|
+
const b = rgb & 0xff
|
|
12
|
+
return { line, bg: `rgba(${r},${g},${b},0.05)` }
|
|
13
|
+
}
|
|
@@ -27,6 +27,7 @@ export function createLegend(): HTMLDetailsElement {
|
|
|
27
27
|
}
|
|
28
28
|
row(false, '成员边(星形,框→成员)')
|
|
29
29
|
row(true, '跨队多属(虚线)')
|
|
30
|
+
row(true, 'roster 网络队框(双线边框)')
|
|
30
31
|
row(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
|
|
31
32
|
row(true, '联邦线(→peer)', 'var(--federal)')
|
|
32
33
|
legend.append(summary, rows)
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @module nexus-stage/planning-menus
|
|
9
9
|
*/
|
|
10
10
|
import { reorderOps, type CanvasAction } from './canvas-ops'
|
|
11
|
+
import { rosterFrameName, rosterFrameTitle } from './planning-roster'
|
|
11
12
|
import type { PlanningSession, PlanningTeam } from './planning-types'
|
|
12
13
|
import type { WorldModel } from './world'
|
|
13
14
|
|
|
@@ -22,6 +23,8 @@ export interface MenusDeps {
|
|
|
22
23
|
readonly model: WorldModel
|
|
23
24
|
/** Canvas teams from the latest poll. */
|
|
24
25
|
teams(): ReadonlyArray<PlanningTeam>
|
|
26
|
+
joined(id: string): boolean
|
|
27
|
+
isRoster(frame: string): boolean
|
|
25
28
|
/** The card or frame-head element a menu re-focuses on close. */
|
|
26
29
|
anchorFor(target: MenuTarget): HTMLElement | null
|
|
27
30
|
emit(a: CanvasAction): void
|
|
@@ -45,6 +48,7 @@ export interface PlanningMenus {
|
|
|
45
48
|
|
|
46
49
|
export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
47
50
|
const { root, netBtn, signal, model } = d
|
|
51
|
+
const titleOf = (frame: string): string => d.isRoster(frame) ? rosterFrameTitle(frame) : frame
|
|
48
52
|
|
|
49
53
|
// ── 建队:selection → 命名对话框 → create-team ──
|
|
50
54
|
function selectedByY(): string[] {
|
|
@@ -87,6 +91,12 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
87
91
|
title.id = 'p-dialog-title'
|
|
88
92
|
title.className = 'p-dialog-title'
|
|
89
93
|
title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
|
|
94
|
+
const kind = document.createElement('select')
|
|
95
|
+
kind.className = 'p-dialog-kind'
|
|
96
|
+
kind.setAttribute('aria-label', '团队类型')
|
|
97
|
+
for (const [value, label] of [['canvas', 'canvas 队'], ['roster', 'roster 网络队']]) {
|
|
98
|
+
const option = document.createElement('option'); option.value = value; option.textContent = label; kind.appendChild(option)
|
|
99
|
+
}
|
|
90
100
|
const input = document.createElement('input')
|
|
91
101
|
input.className = 'p-dialog-input'
|
|
92
102
|
input.maxLength = 40
|
|
@@ -103,7 +113,7 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
103
113
|
cancelBtn.type = 'button'
|
|
104
114
|
cancelBtn.textContent = '取消'
|
|
105
115
|
row.append(okBtn, cancelBtn)
|
|
106
|
-
panel.append(title, input, err, row)
|
|
116
|
+
panel.append(title, kind, input, err, row)
|
|
107
117
|
wrap.appendChild(panel)
|
|
108
118
|
root.appendChild(wrap)
|
|
109
119
|
|
|
@@ -115,25 +125,27 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
115
125
|
}
|
|
116
126
|
const nameError = (): string | null => {
|
|
117
127
|
const name = input.value.trim()
|
|
118
|
-
if (name === '' || name.length > 40 || name.includes('/') || /^\d+$/.test(name))
|
|
119
|
-
|
|
120
|
-
|
|
128
|
+
if (name === '' || name.length > 40 || name.includes('/') || name.startsWith('roster:') || (kind.value === 'roster' && name.includes('*')) || /^\d+$/.test(name)) return '队名需 1..40 字,不含“/”、通配符或保留前缀,不为纯数字'
|
|
129
|
+
if (kind.value === 'roster' && model.getFrame(name) !== undefined) return '同名 canvas 队已存在,不能创建 roster 队'
|
|
130
|
+
if (kind.value === 'roster' && model.getFrame(rosterFrameName(name)) !== undefined && !d.isRoster(rosterFrameName(name))) return 'roster 框名已被 canvas 或镜像队占用'
|
|
131
|
+
if (kind.value === 'canvas' && model.getFrame(rosterFrameName(name)) !== undefined) return '同名 roster 队已存在,不能创建 canvas 队'
|
|
132
|
+
if (kind.value === 'roster' && ids.some(id => model.getNode(id)?.remote === true || !d.joined(id))) return 'roster 仅接受本机已入网的会话'
|
|
121
133
|
return null
|
|
122
134
|
}
|
|
123
135
|
const confirm = (): void => {
|
|
124
136
|
const problem = nameError()
|
|
125
137
|
if (problem !== null) { err.textContent = problem; input.focus(); return }
|
|
126
|
-
const name = input.value.trim()
|
|
138
|
+
const name = kind.value === 'roster' ? rosterFrameName(input.value.trim()) : input.value.trim()
|
|
127
139
|
close()
|
|
128
140
|
if (model.getFrame(name) !== undefined) {
|
|
129
141
|
// Duplicate name: host create is idempotent-ok, but the optimistic
|
|
130
142
|
// create-team would WIPE the existing roster and overwrite its saved
|
|
131
143
|
// frame rect. Join instead — same wire result, nothing destroyed.
|
|
132
|
-
d.notice('info', `已加入现有团队「${name}」`)
|
|
144
|
+
d.notice('info', `已加入现有团队「${kind.value === 'roster' ? rosterFrameTitle(name) : name}」`)
|
|
133
145
|
d.emit({ type: 'add-member', team: name, ids })
|
|
134
146
|
return
|
|
135
147
|
}
|
|
136
|
-
d.emit({ type: 'create-team', name, ids, created: true })
|
|
148
|
+
d.emit({ type: 'create-team', name, ids, created: true, ...(kind.value === 'roster' ? { roster: true } : {}) })
|
|
137
149
|
}
|
|
138
150
|
okBtn.addEventListener('click', confirm)
|
|
139
151
|
cancelBtn.addEventListener('click', close)
|
|
@@ -149,7 +161,7 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
149
161
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
|
|
150
162
|
if (e.key !== 'Tab') return
|
|
151
163
|
e.preventDefault()
|
|
152
|
-
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, button'))
|
|
164
|
+
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, select, button'))
|
|
153
165
|
const idx = focusables.indexOf(document.activeElement as HTMLElement)
|
|
154
166
|
const next = e.shiftKey
|
|
155
167
|
? focusables[(idx - 1 + focusables.length) % focusables.length]
|
|
@@ -252,7 +264,7 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
252
264
|
el.setAttribute('aria-label', '团队操作')
|
|
253
265
|
let items: HTMLButtonElement[] = []
|
|
254
266
|
if (target.kind === 'frame') {
|
|
255
|
-
items = [menuItem(`解散团队「${target.name}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
|
|
267
|
+
items = [menuItem(`解散团队「${titleOf(target.name)}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
|
|
256
268
|
} else {
|
|
257
269
|
const id = target.id
|
|
258
270
|
if (level === 'root') {
|
|
@@ -278,13 +290,17 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
278
290
|
for (const t of d.teams()) {
|
|
279
291
|
if (level === 'join') {
|
|
280
292
|
const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? true
|
|
281
|
-
|
|
293
|
+
const allowed = !d.isRoster(t.name) || (d.joined(id) && model.getNode(id)?.remote !== true)
|
|
294
|
+
const item = menuItem(already ? `${titleOf(t.name)}(已加入)` : titleOf(t.name), !already && allowed, () => d.emit({ type: 'add-member', team: t.name, ids: [id] }))
|
|
295
|
+
if (!allowed) item.title = 'roster 只接受本机已入网会话'
|
|
296
|
+
items.push(item)
|
|
282
297
|
} else if (level === 'leave') {
|
|
283
298
|
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
284
|
-
items.push(menuItem(t.name, member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
|
|
299
|
+
items.push(menuItem(titleOf(t.name), member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
|
|
285
300
|
} else {
|
|
286
301
|
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
287
|
-
|
|
302
|
+
if (d.isRoster(t.name)) continue
|
|
303
|
+
items.push(menuItem(titleOf(t.name), member, () => {
|
|
288
304
|
const current = model.teamMemberIds(t.name)
|
|
289
305
|
const desired = [id, ...current.filter(x => x !== id)]
|
|
290
306
|
d.emit({ type: 'reorder', team: t.name, ops: reorderOps(current, desired) })
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Roster frames use an independent layout namespace beside canvas frames. */
|
|
2
|
+
import type { PlanningTeam } from './planning-types'
|
|
3
|
+
|
|
4
|
+
export const ROSTER_PREFIX = 'roster:'
|
|
5
|
+
export const rosterNameOf = (frame: string): string | undefined => frame.startsWith(ROSTER_PREFIX) ? frame.slice(ROSTER_PREFIX.length) : undefined
|
|
6
|
+
export const rosterFrameName = (name: string): string => ROSTER_PREFIX + name
|
|
7
|
+
export const rosterFrameTitle = (frame: string): string => { const name = rosterNameOf(frame); return name === undefined ? frame : `网络队 · roster · ${name}` }
|
|
8
|
+
export function rosterFrames(rows: readonly { team: string; members: readonly string[] }[], zone: string): PlanningTeam[] {
|
|
9
|
+
return rows.flatMap(row => {
|
|
10
|
+
if (zone === '' || !row.team.startsWith(zone + '/') || !Array.isArray(row.members)) return []
|
|
11
|
+
const name = row.team.slice(zone.length + 1)
|
|
12
|
+
if (name === '' || name.length > 40 || name.includes('/') || name.startsWith(ROSTER_PREFIX) || name.includes('*')) return []
|
|
13
|
+
return [{ name: rosterFrameName(name), team: row.team, kind: 'roster' as const, members: row.members.filter(id => typeof id === 'string' && id !== '').map(id => ({ id })) }]
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -12,7 +12,7 @@ import type { SessionLite, TeamLite } from './world'
|
|
|
12
12
|
|
|
13
13
|
/** Minimal session/team shapes consumed here (topology rows satisfy them). */
|
|
14
14
|
export interface PlanningSession extends SessionLite {}
|
|
15
|
-
export interface PlanningTeam extends TeamLite {}
|
|
15
|
+
export interface PlanningTeam extends TeamLite { kind?: 'roster' }
|
|
16
16
|
|
|
17
17
|
export interface PlanningInput {
|
|
18
18
|
sessions: ReadonlyArray<PlanningSession>
|
|
@@ -41,7 +41,8 @@ import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt
|
|
|
41
41
|
import { boardBounds, fitBelowHud, hudBottomOf } from './planning-fit'
|
|
42
42
|
import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters, NODE_W, NODE_H } from './world'
|
|
43
43
|
import { applyAction, settleAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
|
|
44
|
-
import {
|
|
44
|
+
import { frameHue } from './planning-frame-hue'
|
|
45
|
+
import { rosterFrameTitle } from './planning-roster'
|
|
45
46
|
import { createPlanningMenus } from './planning-menus'
|
|
46
47
|
import { createNodeFlyer, mountBoardSearch } from './planning-search'
|
|
47
48
|
import { applyBoardRelayout } from './planning-relayout'
|
|
@@ -58,23 +59,10 @@ import './planning.css'
|
|
|
58
59
|
export { viewportBucket } from './planning-types'
|
|
59
60
|
export type { PlanningDeps, PlanningInput, PlanningSession, PlanningTeam, PlanningView, SeamKey, SeamPointer, ViewportBucket } from './planning-types'
|
|
60
61
|
|
|
61
|
-
/** Team-name hue, same 31-polynomial family as the 3D scene's getHue. */
|
|
62
|
-
function frameHue(name: string): { line: string; bg: string } {
|
|
63
|
-
let h = 0
|
|
64
|
-
for (let i = 0; i < name.length; i++) h = ((h * 31 + name.charCodeAt(i)) & 0xfffffff) >>> 0
|
|
65
|
-
const rgb = FRAME_HUES[h % FRAME_HUES.length]!
|
|
66
|
-
const line = '#' + rgb.toString(16).padStart(6, '0')
|
|
67
|
-
// 5% alpha fill over the dark base, per the design system.
|
|
68
|
-
const r = (rgb >> 16) & 0xff
|
|
69
|
-
const g = (rgb >> 8) & 0xff
|
|
70
|
-
const b = rgb & 0xff
|
|
71
|
-
return { line, bg: `rgba(${r},${g},${b},0.05)` }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
62
|
|
|
75
63
|
type Gesture =
|
|
76
64
|
| { readonly kind: 'none' }
|
|
77
|
-
| { readonly kind: 'node'; ids: readonly string[]; clicked: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
|
|
65
|
+
| { readonly kind: 'node'; ids: readonly string[]; clicked: string; rosterOrigin?: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
|
|
78
66
|
| { readonly kind: 'frame'; name: string; snap: NonNullable<ReturnType<WorldModel['beginFrameDrag']>>; origin: { x: number; y: number }; moved: boolean }
|
|
79
67
|
| { readonly kind: 'frame-resize'; name: string; dir: string; startRect: { x: number; y: number; w: number; h: number }; origin: { x: number; y: number }; moved: boolean }
|
|
80
68
|
| { readonly kind: 'pan'; last: { x: number; y: number }; moved: boolean }
|
|
@@ -372,7 +360,10 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
372
360
|
const title = el.querySelector<HTMLElement>('.ttl')!
|
|
373
361
|
const cnt = el.querySelector<HTMLElement>('.cnt')!
|
|
374
362
|
const route = el.querySelector<HTMLElement>('.route')!
|
|
375
|
-
|
|
363
|
+
const isRoster = rosterFramesRef.has(name)
|
|
364
|
+
const shownName = isRoster ? rosterFrameTitle(name) : name
|
|
365
|
+
if (title.textContent !== shownName) title.textContent = shownName
|
|
366
|
+
el.classList.toggle('is-roster', isRoster)
|
|
376
367
|
let members = 0
|
|
377
368
|
let cross = 0
|
|
378
369
|
for (const m of model.allNodes()) {
|
|
@@ -385,7 +376,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
385
376
|
if (cnt.textContent !== cntText) cnt.textContent = cntText
|
|
386
377
|
const mirror = mirrorRoute(remoteFramesRef.get(name), pendingTeams.has(name)) // PR#96 single owner + mirror
|
|
387
378
|
const unavailable = mirror !== undefined && reachability.hosts.has(peerHostOf(remoteFramesRef.get(name)!))
|
|
388
|
-
const routeText0 = mirror?.text ??
|
|
379
|
+
const routeText0 = mirror?.text ?? (lastTeams.find(x => x.name === name)?.team ?? name)
|
|
389
380
|
if (route.textContent !== routeText0) route.textContent = routeText0
|
|
390
381
|
route.title = `${mirror?.title ?? ''}${unavailable ? ` · ${UNREACHABLE_HINT}` : ''}`
|
|
391
382
|
showFrameReachability(el, unavailable)
|
|
@@ -400,12 +391,6 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
400
391
|
}
|
|
401
392
|
}
|
|
402
393
|
|
|
403
|
-
/** The mono route line `<host>/canvas/<name>` when the payload carries it. */
|
|
404
|
-
function routeText(name: string): string {
|
|
405
|
-
const t = lastTeams.find(x => x.name === name)
|
|
406
|
-
return t?.team ?? name
|
|
407
|
-
}
|
|
408
|
-
|
|
409
394
|
// ── keyed diff: nodes ──
|
|
410
395
|
function renderNodes(): void {
|
|
411
396
|
const seen = new Set<string>()
|
|
@@ -534,10 +519,17 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
534
519
|
// gh#41: remote-frame registry (frame name → peer URL), refreshed by
|
|
535
520
|
// reconcile and read by emitAction's serverAction to fork remote drops.
|
|
536
521
|
let remoteFramesRef = new Map<string, string>()
|
|
522
|
+
let rosterFramesRef = new Set<string>()
|
|
537
523
|
let reachability = unreachableRefs({})
|
|
538
524
|
const dissolving = createDissolveGuard(deps.now)
|
|
539
525
|
|
|
540
526
|
function emitAction(a: CanvasAction): void {
|
|
527
|
+
const roster = (a.type === 'create-team' && a.roster === true) || rosterFramesRef.has(actionTeam(a))
|
|
528
|
+
if (roster && a.type === 'remove-team' && !window.confirm(`解散网络队「${rosterFrameTitle(a.name)}」并撤回成员与白名单?`)) return
|
|
529
|
+
if (roster && a.type === 'reorder') { notice('error', 'roster 队不支持置顶路由'); return }
|
|
530
|
+
if (roster && (a.type === 'create-team' || a.type === 'add-member') && a.ids.some(id => model.getNode(id)?.remote === true || !lastSessions.some(s => s.id === id && s.joined === true))) {
|
|
531
|
+
notice('error', 'roster 只接受本机已入网会话,远端或未入网成员未写入'); return
|
|
532
|
+
}
|
|
541
533
|
// gh#20 W-B: the SERVER sees zone/id8 remote handles where the model
|
|
542
534
|
// keeps its peer-<host> node id — the canvas member contract is
|
|
543
535
|
// session-id-or-remote-ref. Translation happens here, one boundary;
|
|
@@ -590,14 +582,16 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
590
582
|
})
|
|
591
583
|
}
|
|
592
584
|
}
|
|
585
|
+
if (roster && a.type === 'create-team') rosterFramesRef.add(a.name)
|
|
593
586
|
const undo = applyAction(model, a)
|
|
594
587
|
const team = actionTeam(a)
|
|
595
588
|
pendingAdd(team)
|
|
596
589
|
render()
|
|
597
|
-
const sent = serverAction(a)
|
|
590
|
+
const sent = serverAction(roster ? { ...a, roster: true } : a)
|
|
598
591
|
void deps.onCanvasAction(sent).then(
|
|
599
592
|
ok => {
|
|
600
593
|
pendingRelease(team)
|
|
594
|
+
if (!ok && a.type === 'create-team' && roster) rosterFramesRef.delete(team)
|
|
601
595
|
layoutDoc = settleAction(model, a, ok, undo, layoutDoc); markDirty(); dissolving.settled(sent, ok)
|
|
602
596
|
render()
|
|
603
597
|
},
|
|
@@ -606,6 +600,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
606
600
|
// team's guard (and its optimistic state) forever — and per §3.4
|
|
607
601
|
// 不吞错, the rollback surfaces to the user.
|
|
608
602
|
pendingRelease(team)
|
|
603
|
+
if (a.type === 'create-team' && roster) rosterFramesRef.delete(team)
|
|
609
604
|
layoutDoc = settleAction(model, a, false, undo, layoutDoc); markDirty(); dissolving.settled(sent, false)
|
|
610
605
|
notice('error', '操作通道异常,已回滚')
|
|
611
606
|
render()
|
|
@@ -628,6 +623,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
628
623
|
const menus = createPlanningMenus({
|
|
629
624
|
root, netBtn, signal, model,
|
|
630
625
|
teams: () => lastTeams,
|
|
626
|
+
joined: id => lastSessions.some(s => s.id === id && s.joined === true),
|
|
627
|
+
isRoster: name => rosterFramesRef.has(name),
|
|
631
628
|
anchorFor: (t) => (t.kind === 'node' ? nodeEls.get(t.id) : frameEls.get(t.name)) ?? null,
|
|
632
629
|
emit: emitAction,
|
|
633
630
|
notice,
|
|
@@ -698,8 +695,11 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
698
695
|
}
|
|
699
696
|
}
|
|
700
697
|
}
|
|
701
|
-
//
|
|
702
|
-
|
|
698
|
+
// Canvas blank drops move only; leaving a roster frame explicitly retracts those declarations.
|
|
699
|
+
if (gesture.rosterOrigin !== undefined && f !== gesture.rosterOrigin) {
|
|
700
|
+
const leaving = gesture.ids.filter(id => model.teamMemberIds(gesture.rosterOrigin!).includes(id))
|
|
701
|
+
if (leaving.length > 0) emitAction({ type: 'remove-member', team: gesture.rosterOrigin, ids: leaving })
|
|
702
|
+
}
|
|
703
703
|
}
|
|
704
704
|
|
|
705
705
|
function pointerDown(ev: SeamPointer): void {
|
|
@@ -768,7 +768,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
768
768
|
const already = model.isSelected(id)
|
|
769
769
|
if (!already && !ev.shiftKey) model.setSelection([id])
|
|
770
770
|
const ids = ev.shiftKey || already ? (model.isSelected(id) ? model.selectedIds() : [...model.selectedIds(), id]) : [id]
|
|
771
|
-
|
|
771
|
+
const origin = innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y))
|
|
772
|
+
gesture = { kind: 'node', clicked: id, ids, ...(origin && rosterFramesRef.has(origin) && model.teamMemberIds(origin).includes(id) ? { rosterOrigin: origin } : {}), last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
|
|
772
773
|
nodeEl.classList.add('dragging')
|
|
773
774
|
} else if (headEl !== null) {
|
|
774
775
|
if (ev.button !== 0) return
|
|
@@ -1126,6 +1127,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1126
1127
|
function reconcile(input: PlanningInput): void {
|
|
1127
1128
|
// gh#41: remote-frame registry shared with serverAction (drop fork).
|
|
1128
1129
|
lastTeams = input.teams
|
|
1130
|
+
rosterFramesRef = new Set([...input.teams.filter(t => t.kind === 'roster').map(t => t.name), ...[...rosterFramesRef].filter(n => pendingTeams.has(n))])
|
|
1129
1131
|
lastPeers = input.peers ?? []
|
|
1130
1132
|
federation.setInFlight(input.inFlight ?? [])
|
|
1131
1133
|
// Unjoined sessions have no node on the canvas — the panel-slim
|
|
@@ -83,6 +83,8 @@
|
|
|
83
83
|
#dsh-plan .p-frame-head .cnt{color:var(--ink-muted); font-weight:400}
|
|
84
84
|
#dsh-plan .p-frame-head .route{font-size:10.5px; color:var(--ink-muted)}
|
|
85
85
|
#dsh-plan .p-frame.is-mirror{border-style:dashed}
|
|
86
|
+
#dsh-plan .p-frame.is-roster{border-style:double;border-width:3px}
|
|
87
|
+
#dsh-plan .p-frame.is-roster .p-frame-head{border-style:dashed}
|
|
86
88
|
#dsh-plan .p-frame.is-syncing .p-frame-head .route{color:var(--accent)}
|
|
87
89
|
#dsh-plan .p-frame.is-unreachable{opacity:.76;border-style:dashed}
|
|
88
90
|
#dsh-plan .p-frame-unreachable{position:absolute;left:8px;top:20px;max-width:calc(100% - 16px);color:var(--uupm-error);font-size:11px;pointer-events:none}
|
|
@@ -150,6 +150,7 @@ export interface ActivityRow {
|
|
|
150
150
|
|
|
151
151
|
/** The /state subset the workbench consumes (additive fields ignored). */
|
|
152
152
|
export interface StateBody {
|
|
153
|
+
readonly zone?: string
|
|
153
154
|
readonly version?: string
|
|
154
155
|
readonly sessions?: readonly SessionRow[]
|
|
155
156
|
readonly canvas?: { teams?: readonly CanvasTeamRow[] }
|
|
@@ -176,6 +177,7 @@ export interface StateBody {
|
|
|
176
177
|
readonly archived?: readonly ArchivedRow[]
|
|
177
178
|
/** gh#41 fleet planning: each tracked peer's published canvas org. */
|
|
178
179
|
readonly peerOrg?: readonly PeerOrgRow[]
|
|
180
|
+
readonly registry?: { teams?: ReadonlyArray<{ team: string; members: readonly string[]; live?: number }> }
|
|
179
181
|
readonly archivedCount?: number
|
|
180
182
|
/** Settled-archive split by verdict (the receipt outcome vocabulary — 'completed' | 'rejected' | 'failed' | … | 'abandoned' — or 'unclassified'). */
|
|
181
183
|
readonly archivedSplit?: Readonly<Record<string, number>>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nelsonlongxiang/dsh-open-a2a-net",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.33",
|
|
4
4
|
"description": "Open A2A network plugin for DeepSeek Harness: signed agent cards, decentralized peer/zone discovery, direct routing model tools, and joinable session nodes with a sidebar control",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -84,8 +84,9 @@
|
|
|
84
84
|
"test:mesh": "vitest run tests/mesh",
|
|
85
85
|
"test:live": "vitest run tests/live",
|
|
86
86
|
"test:build-layout": "vitest run tests/gates/build-layout.spec.ts",
|
|
87
|
+
"verify:docs": "node scripts/doc-drift.mjs",
|
|
87
88
|
"verify:nexus": "node scripts/verify-nexus-ci.mjs && npm --prefix nexus-stage run build && npm --prefix nexus-stage run test && git diff --exit-code -- assets/nexusDist/assets && git diff --ignore-cr-at-eol --exit-code -- assets/nexusDist/index.html",
|
|
88
|
-
"verify": "pnpm typecheck && pnpm build && pnpm test && pnpm test:build-layout && pnpm verify:nexus"
|
|
89
|
+
"verify": "pnpm typecheck && pnpm verify:docs && pnpm build && pnpm test && pnpm test:build-layout && pnpm verify:nexus"
|
|
89
90
|
},
|
|
90
91
|
"peerDependencies": {
|
|
91
92
|
"@deepseek-ai/cordis": ">=4.0.1",
|