@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.
@@ -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;
@@ -74,6 +74,7 @@ export interface AdminFaceDeps extends Pick<CanvasNotifyDeps, 'verifyOriginClaim
74
74
  team: string;
75
75
  id: string;
76
76
  ok: boolean;
77
+ actor?: string;
77
78
  reason?: string;
78
79
  }[];
79
80
  readonly reportRing: {
@@ -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 keys mirror
70
- * canvas team names (no '/', cap 40); rects need w>0 and h>0; caps are
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 (typeof name !== 'string' || name === '' || name.includes('/') || name.length > 40) continue
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 (typeof name !== 'string' || name === '' || name.includes('/') || name.length > 40) continue
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
@@ -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 { CanvasAction } from './canvas-ops'
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
- return '队名需 1..40 字,不含“/”,不为纯数字'
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
- items.push(menuItem(already ? `${t.name}(已加入)` : t.name, !already, () => d.emit({ type: 'add-member', team: t.name, ids: [id] })))
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
- items.push(menuItem(t.name, member, () => {
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 { FRAME_HUES } from './tokens'
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
- if (title.textContent !== name) title.textContent = name
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 ?? routeText(name)
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
- // Blank drop: a position move only. No roster writes — the explicit
702
- // context-menu 离队 (and the node drawer) remain the removal paths.
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
- gesture = { kind: 'node', clicked: id, ids, last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
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.32",
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",