@nelsonlongxiang/dsh-open-a2a-net 0.6.29 → 0.6.31

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.
Files changed (38) hide show
  1. package/README.md +2 -0
  2. package/README_zh.md +2 -0
  3. package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
  4. package/assets/nexusDist/assets/index-jm-cFAhB.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +624 -624
  7. package/lib/canvas-store.js +21 -1
  8. package/lib/client.js +64 -64
  9. package/lib/client.js.map +1 -1
  10. package/lib/faces/admin.js +12 -1
  11. package/lib/faces/canvas-notify.js +138 -0
  12. package/lib/faces/wire.js +669 -0
  13. package/lib/flood-guard.js +106 -0
  14. package/lib/index.js +69 -663
  15. package/lib/skill/SKILL.md +1 -0
  16. package/lib/skill/references/collaboration.md +376 -0
  17. package/lib/task-ledger.js +34 -4
  18. package/lib/types/canvas-store.d.ts +10 -1
  19. package/lib/types/faces/admin.d.ts +2 -1
  20. package/lib/types/faces/canvas-notify.d.ts +74 -0
  21. package/lib/types/faces/wire.d.ts +171 -0
  22. package/lib/types/flood-guard.d.ts +29 -0
  23. package/lib/types/runtime.d.ts +1 -0
  24. package/lib/types/task-ledger.d.ts +9 -1
  25. package/nexus-stage/src/canvas-ops.ts +16 -1
  26. package/nexus-stage/src/canvas-wire.ts +27 -19
  27. package/nexus-stage/src/main.ts +5 -1
  28. package/nexus-stage/src/planning-edges.ts +26 -0
  29. package/nexus-stage/src/planning-mirror.ts +31 -0
  30. package/nexus-stage/src/planning-overlap.ts +36 -11
  31. package/nexus-stage/src/planning-types.ts +1 -0
  32. package/nexus-stage/src/planning-undo.ts +68 -0
  33. package/nexus-stage/src/planning-view.ts +61 -37
  34. package/nexus-stage/src/planning.css +2 -0
  35. package/nexus-stage/src/world.ts +8 -20
  36. package/package.json +1 -1
  37. package/assets/nexusDist/assets/index-BqepxJ7E.css +0 -1
  38. package/assets/nexusDist/assets/index-CTWH6QRh.js +0 -9
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The /a2a/* wire faces (gh#77 batch B2; slice 2 adds log + query).
3
+ *
4
+ * Moved verbatim out of apply() in src/index.ts: the routes register when
5
+ * the web server settles, exactly as before. What used to be closure
6
+ * captures arrives through WireDeps and is destructured once below, so the
7
+ * route bodies read as they did in index.ts (the B1 admin.ts pattern). The
8
+ * one accessor deviation: the log face's `legacyInboundCount += 1` (a let in
9
+ * index.ts) goes through `d.bumpLegacyInbound()`, the same mutable-binding
10
+ * treatment admin.ts gives its six lets. direct stays inline for slice 3.
11
+ * @module src/faces/wire
12
+ */
13
+ import type { IncomingMessage, ServerResponse } from 'node:http';
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ import type { Agent } from '@deepseek-ai/dsh-agent';
16
+ import type WebServer from '@deepseek-ai/dsh-host-webserver';
17
+ import type { A2aPeerCard } from '../types.ts';
18
+ import { CanvasStore } from '../canvas-store.ts';
19
+ import type { CliDepositStore } from '../cli-deposit.ts';
20
+ import type { IdempotencyStore } from '../idempotency-store.ts';
21
+ import type { JoinedSessions } from '../joined-store.ts';
22
+ import { type TaskLedger } from '../task-ledger.ts';
23
+ import type { Config } from '../index.ts';
24
+ /** The cold-wake answer (mirrors index.ts's local ColdWake). */
25
+ export type ColdWake = {
26
+ readonly kind: 'flight';
27
+ readonly flight: Promise<Agent>;
28
+ } | {
29
+ readonly kind: 'no-face';
30
+ } | {
31
+ readonly kind: 'archived';
32
+ } | {
33
+ readonly kind: 'no-match';
34
+ };
35
+ /** routeIntoAgent's settled answer (mirrors index.ts's local union). */
36
+ export type RouteOutcome = {
37
+ ok: true;
38
+ reply: string;
39
+ status?: string;
40
+ bridge?: 'native-teams';
41
+ placeholder?: boolean;
42
+ } | {
43
+ ok: false;
44
+ error: string;
45
+ };
46
+ interface BridgeTeamsRegistryMirror {
47
+ listTeams(): Array<{
48
+ name: string;
49
+ description: string;
50
+ }>;
51
+ describeTarget(handle: string): Promise<{
52
+ plane: 'local' | 'a2a';
53
+ ambiguous?: boolean;
54
+ localLabel?: string;
55
+ }>;
56
+ startRound(args: {
57
+ team?: string;
58
+ message: string;
59
+ }, parent: {
60
+ id: string;
61
+ session: {
62
+ events?: readonly unknown[];
63
+ };
64
+ }, signal: AbortSignal): Promise<string>;
65
+ }
66
+ /** nativeTeamsPrepare's answer (mirrors index.ts's local union). */
67
+ export type NativePrepared = {
68
+ readonly ok: true;
69
+ readonly teams: BridgeTeamsRegistryMirror;
70
+ readonly parent: {
71
+ id: string;
72
+ session: {
73
+ events?: readonly unknown[];
74
+ };
75
+ };
76
+ } | {
77
+ readonly ok: false;
78
+ readonly reason: 'not-claimed';
79
+ } | {
80
+ readonly ok: false;
81
+ readonly reason: 'error';
82
+ readonly error: string;
83
+ };
84
+ /** nativeTeamsRound's settled answer (mirrors index.ts's local union). */
85
+ export type NativeRoundOutcome = {
86
+ readonly ok: true;
87
+ readonly reply: string;
88
+ readonly settled: boolean;
89
+ } | {
90
+ readonly ok: false;
91
+ readonly error: string;
92
+ readonly phase?: 'pre-dispatch';
93
+ readonly aborted?: boolean;
94
+ };
95
+ /** What verifyOriginClaim answers (see index.ts; reason widens to string here — the faces only interpolate it). */
96
+ export type OriginClaimResult = {
97
+ ok: true;
98
+ caller: string;
99
+ callerUrl: string;
100
+ card: A2aPeerCard;
101
+ } | {
102
+ ok: false;
103
+ reason: string;
104
+ };
105
+ /** The closure captures the wire faces read (grows with each extracted face). */
106
+ export interface WireDeps {
107
+ readonly ctx: Context;
108
+ readonly logger: ReturnType<Context['logger']>;
109
+ readonly config: Config;
110
+ readonly cliDeposits: CliDepositStore;
111
+ readonly canvasStore: CanvasStore;
112
+ readonly sessionNodes: Map<string, Agent>;
113
+ readonly joinedSessions: JoinedSessions;
114
+ readonly idempotencyStore: IdempotencyStore;
115
+ readonly verifyOriginClaim: (req: IncomingMessage) => Promise<OriginClaimResult>;
116
+ readonly callerLinkedToThisHost: (caller: string, callerUrl: string, callerCard: A2aPeerCard | undefined) => boolean;
117
+ readonly recordActivity: (dir: 'in' | 'out', team: string, peer: string, ok: boolean) => void;
118
+ readonly bumpLegacyInbound: () => void;
119
+ readonly secretEqual: (a: string, b: string) => boolean;
120
+ readonly a2aCallerRefusal: (caller: string, targetTeam?: string) => string | undefined;
121
+ readonly scanSessionLogList: () => Array<{
122
+ id: string;
123
+ mtimeMs: number;
124
+ sizeBytes: number;
125
+ }>;
126
+ readonly readSessionLogTail: (id: string, limit: number) => {
127
+ ok: true;
128
+ id: string;
129
+ messages: Array<{
130
+ role: string;
131
+ text: string;
132
+ time?: number;
133
+ }>;
134
+ } | {
135
+ ok: false;
136
+ error: string;
137
+ };
138
+ readonly taskLedger: TaskLedger;
139
+ readonly refuseIfFlooded: (caller: string, message: string, team: string, taskId: string, res: ServerResponse) => boolean;
140
+ readonly settleAndAnnounce: (message: string) => void;
141
+ readonly touchWakeDemand: () => void;
142
+ readonly session: string;
143
+ readonly resolveAgentForTeam: (team: string) => Agent | undefined;
144
+ readonly parseCanvasTeamName: (team: string) => string | undefined;
145
+ readonly canvasLiveAgent: (name: string | undefined, skipId?: string) => Agent | undefined;
146
+ readonly wakeColdTeam: (team: string, skipId?: string) => ColdWake;
147
+ readonly probeConsumption: (agent: Agent, taskId: string) => boolean;
148
+ readonly steerRelay: (agent: Agent, text: string, sender?: string) => void;
149
+ readonly fromClause: (from: string) => string;
150
+ readonly armReceiptAutosend: (target: Agent, taskId: string, callerTeam: string | undefined, deliveredText: string) => void;
151
+ readonly receiptExpectationOf: (receiptTarget: string) => 'receivable' | 'process-label';
152
+ readonly armAsyncNudge: (agent: Agent, team: string, taskId: string, delayMs: number) => void;
153
+ readonly liveAgent: () => Agent | undefined;
154
+ readonly callerIsAgent: (agent: Agent, caller: string) => boolean;
155
+ readonly selfRouteText: (team: string) => string;
156
+ readonly noWakeFaceText: (team: string) => string;
157
+ readonly archivedColdMatchText: (team: string) => string;
158
+ readonly noColdMatchText: (team: string) => string;
159
+ readonly nativeTeamsPrepare: (team: string) => Promise<NativePrepared>;
160
+ readonly nativeTeamsRound: (prepared: Extract<NativePrepared, {
161
+ ok: true;
162
+ }>, team: string, caller: string, message: string, taskId: string | undefined) => Promise<NativeRoundOutcome>;
163
+ readonly routeIntoAgent: (team: string, message: string, caller: string, taskId?: string) => Promise<RouteOutcome>;
164
+ }
165
+ /**
166
+ * Register the small wire faces on a settled web server.
167
+ * @param webServer - the settled host web server.
168
+ * @param d - the captures listed on {@link WireDeps}.
169
+ */
170
+ export declare function registerWireFaces(webServer: WebServer, d: WireDeps): void;
171
+ export {};
@@ -0,0 +1,29 @@
1
+ /** gh#91 transitional ingress brakes. One guard per apply(), never process-global.
2
+ * Run AFTER admission, OriginClaim and the fresh idempotency claim, BEFORE
3
+ * settlement/mailbox/steer. Replays keep 409 and spend no delivery quota.
4
+ * All refusals carry a stable reason, task id, failed query outcome and an
5
+ * activity event. This is not the independent file channel or deployment.
6
+ */
7
+ import type { ServerResponse } from 'node:http';
8
+ import type { IdempotencyStore } from './idempotency-store.ts';
9
+ export declare const WIRE_ERROR_RATE_LIMITED = -32008;
10
+ export declare const MAX_PARTS_PER_TRANSFER = 16;
11
+ export declare const DEFAULT_RATE_PER_MINUTE = 16;
12
+ type Reason = 'sender-rate-limited' | 'file-parts-exceeded' | 'file-part-invalid' | 'file-part-duplicate' | 'flood-state-full';
13
+ export declare class FloodGuard {
14
+ private readonly now;
15
+ private readonly senders;
16
+ private readonly transfers;
17
+ constructor(now?: () => number);
18
+ /** Check/admit one fresh task. No timer; maps and per-key sets are bounded.
19
+ * Active transfer entries NEVER evict to make space (which would reset a
20
+ * transfer's allowance); capacity exhaustion refuses visibly instead.
21
+ * Counts are boot-local: restart resets them, disclosed in the guide.
22
+ */
23
+ check(caller: string, message: string): Reason | undefined;
24
+ }
25
+ /** Host-scoped adapter; rejected fresh keys are FAILED, not stranded pending.
26
+ * HTTP 200 retains the existing client's structured peer-error path.
27
+ */
28
+ export declare function createFloodGuard(outcomes: Pick<IdempotencyStore, 'recordOutcome'>, recordActivity: (dir: 'in' | 'out', team: string, peer: string, ok: boolean) => void): (caller: string, message: string, team: string, taskId: string, res: ServerResponse) => boolean;
29
+ export {};
@@ -31,4 +31,5 @@ export interface InFlightRoute {
31
31
  export interface CardCacheEntry {
32
32
  at: number;
33
33
  card: A2aPeerCard | undefined;
34
+ missAt?: number;
34
35
  }
@@ -53,7 +53,7 @@ export type TaskStatus = 'pending' | 'dead';
53
53
  /** F5 layer-3: why a dead row aged out (stamped by the sweep at flip time). */
54
54
  export type DeadReason = 'fire-forget' | 'unconsumed';
55
55
  /** gh#31: which archive path ended a row — a receipt, a content echo, or the caller. */
56
- export type ArchiveEnd = 'receipt' | 'echo' | 'abandoned' | 'cancelled';
56
+ export type ArchiveEnd = 'receipt' | 'echo' | 'abandoned' | 'cancelled' | 'evicted';
57
57
  /** One tracked outbound task still in the owed book (unsettled). */
58
58
  export interface TaskRecord {
59
59
  readonly taskId: string;
@@ -323,6 +323,14 @@ export declare class TaskLedger {
323
323
  resolveEcho(message: string): string[];
324
324
  /** Move one settled record to the archive head, trimming at the cap. */
325
325
  private archiveSettled;
326
+ /**
327
+ * gh#86 (chief order direct-4f596524): when the owed book exceeds
328
+ * TASK_CAP, the overflow rows go to the ARCHIVE as evicted - visible
329
+ * for audit, never silently dropped. The review seat's recommendation,
330
+ * chief-endorsed: the silent cap slice lost the oldest pending rows
331
+ * with no trace. L8 criterion: zero silently-dropped.
332
+ */
333
+ private trimOwed;
326
334
  /** Lazy dead-letter sweep: overdue pending rows flip status, at most once. */
327
335
  private sweep;
328
336
  /** Load a persisted snapshot on construction, if present. */
@@ -13,13 +13,14 @@
13
13
  */
14
14
 
15
15
  import { nodeRect, deriveInitialFrame, type WorldModel } from './world'
16
+ import type { LayoutDoc } from './layout-doc'
16
17
 
17
18
  /** One pre-computed host op (remove = remove-member, add = add-member). */
18
19
  export type RosterOp = { op: 'remove' | 'add'; id: string }
19
20
 
20
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. */
21
22
  export type CanvasAction =
22
- | { type: 'create-team'; name: string; ids: readonly string[]; /** true when the model had no such team before this action (a serial failure then leaves a ghost EMPTY team on the host — the wire compensates with a remove). */ created?: boolean }
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 }
23
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 }
24
25
  | { type: 'remove-member'; team: string; ids: readonly string[]; /** gh#41: see add-member. */ fleetPeer?: string }
25
26
  | { type: 'remove-team'; name: string }
@@ -123,6 +124,20 @@ export function applyAction(model: WorldModel, a: CanvasAction): Undo {
123
124
  }
124
125
  }
125
126
 
127
+ /** Settle the optimistic delta and forget geometry only for a removed frame.
128
+ * Never prune all saved frames: remote/offline teams may be absent this poll.
129
+ * The caller schedules a correcting save even on rollback, so an earlier
130
+ * in-flight layout POST cannot leave optimistic geometry persisted forever.
131
+ */
132
+ export function settleAction(model: WorldModel, a: CanvasAction, ok: boolean, undo: Undo, layout: LayoutDoc | undefined): LayoutDoc | undefined {
133
+ if (!ok) undo()
134
+ const removed = (a.type === 'remove-team' && ok) || (a.type === 'create-team' && !ok && a.created === true)
135
+ if (!removed || layout === undefined || model.getFrame(actionTeam(a)) !== undefined) return layout
136
+ const frames = { ...layout.frames }
137
+ delete frames[actionTeam(a)]
138
+ return { ...layout, frames }
139
+ }
140
+
126
141
  /**
127
142
  * The minimal remove+add walk that turns `current` into `desired` on a
128
143
  * host whose add-member always APPENDS: walk desired positions in order;
@@ -7,14 +7,12 @@
7
7
  * Classification per request:
8
8
  * - `ok:true` → success (silent)
9
9
  * - `ok:false` WITH an `error` string → error notice, verbatim
10
- * - `ok:false` without `error` (absent target, → idempotent no-op,
11
- * member cap hit) silent success
10
+ * - `ok:false` without `error` → success only when returned members/teams
11
+ * prove the requested idempotent effect; cap/unknown results are failures
12
12
  * - HTTP >= 400 → body error verbatim, else `HTTP <status>`
13
13
  * - send() throws → `画布不可达:<message>`
14
- * - 200 + ok:false WITHOUT an error string → idempotent no-op
15
- * (absent target, member/team cap hit) — silent success
16
- * - 200 + non-JSON body (proxy artifact) → classified as success
17
- * (known limitation: nothing parseable to classify)
14
+ * - missing success acknowledgement (including non-JSON transport errors)
15
+ * → visible failure, never a confirmed join
18
16
  *
19
17
  * Ops on the same team run through a serial queue (a later gesture waits
20
18
  * instead of racing; cross-team ops run in parallel) - mirrors the
@@ -38,10 +36,9 @@ export interface CanvasWireDeps {
38
36
  }
39
37
 
40
38
  export interface CanvasWire {
41
- /** create → add-member per id, serial, first failure stops. `created`:
42
- * the team did not exist model-side before this action — a first-add
43
- * failure would then leave a ghost EMPTY team on the host, compensated
44
- * with a best-effort remove. */
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. */
45
42
  createTeam(name: string, ids: readonly string[], created?: boolean): Promise<boolean>
46
43
  addMembers(team: string, ids: readonly string[]): Promise<boolean>
47
44
  removeMembers(team: string, ids: readonly string[]): Promise<boolean>
@@ -54,7 +51,7 @@ export interface CanvasWire {
54
51
  type RosterOp = { op: 'remove' | 'add'; id: string }
55
52
 
56
53
  function errorText(body: CanvasWireResult['body']): string | undefined {
57
- return typeof body.error === 'string' ? body.error : undefined
54
+ return body != null && typeof body.error === 'string' ? body.error : undefined
58
55
  }
59
56
 
60
57
  export function createCanvasWire(deps: CanvasWireDeps): CanvasWire {
@@ -97,13 +94,25 @@ export function createCanvasWire(deps: CanvasWireDeps): CanvasWire {
97
94
  deps.onNotice('error', errorText(res.body) ?? `HTTP ${res.status}`)
98
95
  return false
99
96
  }
100
- if (res.body.ok === true) return true
101
- const text = errorText(res.body)
97
+ if (res.body?.ok === true) return true
98
+ const text = res.body != null && typeof res.body === 'object' ? errorText(res.body) : undefined
102
99
  if (text !== undefined) {
103
100
  deps.onNotice('error', text)
104
101
  return false
105
102
  }
106
- return true // ok:false without error: idempotent no-op (absent target / cap)
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
107
116
  }
108
117
 
109
118
  async function serial(body: Record<string, unknown>, ids: readonly string[]): Promise<boolean> {
@@ -114,15 +123,14 @@ export function createCanvasWire(deps: CanvasWireDeps): CanvasWire {
114
123
  }
115
124
 
116
125
  return {
117
- createTeam(name, ids, created = false) {
126
+ createTeam(name, ids) {
118
127
  return enqueue(name, async () => {
119
128
  if (!(await raw({ action: 'create', name }))) return false
120
129
  for (let i = 0; i < ids.length; i++) {
121
130
  if (!(await raw({ action: 'add-member', name, id: ids[i]! }))) {
122
- // A failure on the FIRST add of a team this action created leaves
123
- // a ghost EMPTY team on the host — compensate with a remove.
124
- // Later failures keep whatever members genuinely joined.
125
- if (created && i === 0) await raw({ action: 'remove', name })
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).
126
134
  return false
127
135
  }
128
136
  }
@@ -167,7 +167,11 @@ function runCanvasAction(a: CanvasAction): Promise<boolean> {
167
167
  cache: 'no-store',
168
168
  }).catch(() => undefined)
169
169
  const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
170
- if (body?.ok !== true) return false
170
+ if (body?.ok !== true) {
171
+ // Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
172
+ planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
173
+ return false
174
+ }
171
175
  }
172
176
  return true
173
177
  })()
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The membership spokes' SVG layer (edges.ts stays pure geometry). Moved
3
+ * out of planning-view (module-size cap) when pointercancel landed.
4
+ * @module nexus-stage/planning-edges
5
+ */
6
+ import { starEdges } from './edges'
7
+ import type { WorldModel } from './world'
8
+
9
+ /**
10
+ * Stateless, rebuilt wholesale. Derived from the MODEL (not the host's
11
+ * teams): optimistic membership changes must repaint their edges
12
+ * immediately, before the host confirms.
13
+ */
14
+ export function drawMemberEdges(svg: SVGSVGElement, model: WorldModel): void {
15
+ while (svg.firstChild !== null) svg.firstChild.remove()
16
+ const teams = model.allFrames().map(([name]) => ({ name, members: model.teamMemberIds(name).map(id => ({ id })) }))
17
+ for (const e of starEdges(teams, new Map(model.allFrames()), model.positions())) {
18
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
19
+ line.setAttribute('class', 'e-member' + (e.dashed ? ' dashed' : ''))
20
+ line.setAttribute('x1', String(e.x1))
21
+ line.setAttribute('y1', String(e.y1))
22
+ line.setAttribute('x2', String(e.x2))
23
+ line.setAttribute('y2', String(e.y2))
24
+ svg.appendChild(line)
25
+ }
26
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Owner vs mirror on the board (owner ruling 2026-10-29, PR#96: canvas sync
3
+ * is single owner + mirror). A team belongs to the host that made it; every
4
+ * other host renders it as a MIRROR of that host's published card and edits
5
+ * it through the fleet relay. The frame head says so: 「镜像 · <owner host>」,
6
+ * and 「同步中…」 from the moment a relayed edit is sent until the owner
7
+ * answers (the relay refreshes the owner's card before replying, so the next
8
+ * poll already shows the result). Local frames carry no marker — this host
9
+ * is their owner.
10
+ * @module nexus-stage/planning-mirror
11
+ */
12
+ import { peerHostOf } from './federation'
13
+
14
+ export interface MirrorRoute {
15
+ readonly text: string
16
+ readonly title: string
17
+ readonly syncing: boolean
18
+ }
19
+
20
+ /** The route line of a remote (mirrored) frame; undefined for a local frame. */
21
+ export function mirrorRoute(ownerUrl: string | undefined, syncing: boolean): MirrorRoute | undefined {
22
+ if (ownerUrl === undefined) return undefined
23
+ const host = peerHostOf(ownerUrl)
24
+ return {
25
+ text: syncing ? `同步中… · ${host}` : `镜像 · ${host}`,
26
+ title: syncing
27
+ ? `已把改动交给属主 ${host},等待它确认`
28
+ : `这个团队属于 ${host},这里显示的是它的镜像;拖入或移出成员会经中继交给 ${host} 执行`,
29
+ syncing,
30
+ }
31
+ }
@@ -9,9 +9,14 @@
9
9
  * frame the user just touched stays where it was put; a remote org frame
10
10
  * never moves on its own (gh#44: its rect is the user's), so a local frame
11
11
  * yields to it; two remote frames are left alone.
12
- * - Card vs frame: a card lying on a frame it does not belong to moves just
13
- * outside that frame (right of it, else below it, else right of every
14
- * frame). Members are never moved off their own frame.
12
+ * - Card vs frame: a card lying on a frame it does not belong to goes home
13
+ * first — the first free grid seat inside its own frame (memberships in
14
+ * state order) — and only a card with no frame (or no free seat) goes
15
+ * outside: right of that frame, else below it, else down a column right of
16
+ * every frame. No landing spot covers another card or a foreign frame, and
17
+ * frames are never moved or resized by this step. Members are never moved
18
+ * off their own frame; a member merely outside its frame (PR#85: a frame
19
+ * moves only itself) is left where it is.
15
20
  * Callers run it after a frame drag or resize, after 整理成员 / 一键重排,
16
21
  * and once when the board first has content — never on every poll (a poll
17
22
  * does not reposition what the user arranged).
@@ -74,22 +79,45 @@ export function separateFrames(model: WorldModel, remote: ReadonlySet<string>, t
74
79
  return changed
75
80
  }
76
81
 
82
+ /** Grid of the 整理成员 layout, shared by eviction seats. */
83
+ const PAD = 28
84
+ const GAP_X = 16
85
+ const GAP_Y = 14
86
+
87
+ /** Card-centred seats inside a frame, row by row (the 整理成员 grid). */
88
+ function seatsIn(f: Rect): Array<{ x: number; y: number }> {
89
+ const out: Array<{ x: number; y: number }> = []
90
+ for (let y = f.y + PAD; y + NODE_H <= f.y + f.h - PAD; y += NODE_H + GAP_Y) {
91
+ for (let x = f.x + PAD; x + NODE_W <= f.x + f.w - PAD; x += NODE_W + GAP_X) out.push({ x: x + NODE_W / 2, y: y + NODE_H / 2 })
92
+ }
93
+ return out
94
+ }
95
+
77
96
  /** Move cards off frames they do not belong to; true when anything moved. */
78
97
  export function evictStrangers(model: WorldModel): boolean {
79
98
  let changed = false
80
99
  const frames = model.allFrames()
81
- const clear = (r: Rect, own: ReadonlySet<string>): boolean => frames.every(([name, f]) => own.has(name) || !hit(r, f))
100
+ const rectOf = new Map(frames)
82
101
  for (const n of model.allNodes()) {
83
102
  const own = new Set(n.memberships.map(m => m.team))
84
- const r = nodeRect(n)
85
- const host = frames.find(([name, f]) => !own.has(name) && hit(r, f))
103
+ const host = frames.find(([name, f]) => !own.has(name) && hit(nodeRect(n), f))
86
104
  if (host === undefined) continue
87
105
  const f = host[1]
88
- const at = (x: number, y: number): Rect => ({ x: x - NODE_W / 2, y: y - NODE_H / 2, w: NODE_W, h: NODE_H })
106
+ // Free = no foreign frame and no other card under it (cards read live: earlier evictions count).
107
+ const free = (p: { x: number; y: number }): boolean => {
108
+ const r = nodeRect(p)
109
+ return frames.every(([name, g]) => own.has(name) || !hit(r, g)) &&
110
+ model.allNodes().every(o => o.id === n.id || !hit(r, nodeRect(o)))
111
+ }
112
+ const home = n.memberships.map(m => rectOf.get(m.team)).filter((g): g is FrameGeom => g !== undefined)
113
+ const seat = home.flatMap(seatsIn).find(free)
89
114
  const right = { x: f.x + f.w + OVERLAP_GAP + NODE_W / 2, y: n.y }
90
115
  const below = { x: n.x, y: f.y + f.h + OVERLAP_GAP + NODE_H / 2 }
116
+ // Last resort: a column right of every frame, top-down from this frame. A card blocks
117
+ // at most two seats of this pitch, so 2·cards+1 seats always hold a free one.
91
118
  const farX = Math.max(...frames.map(([, g]) => g.x + g.w)) + OVERLAP_GAP + NODE_W / 2
92
- const to = clear(at(right.x, right.y), own) ? right : clear(at(below.x, below.y), own) ? below : { x: farX, y: n.y }
119
+ const column = Array.from({ length: 2 * model.allNodes().length + 1 }, (_, i) => ({ x: farX, y: f.y + NODE_H / 2 + i * (NODE_H + GAP_Y) }))
120
+ const to = seat ?? [right, below, ...column].find(free) ?? column[column.length - 1]!
93
121
  model.dragNodes([n.id], to.x - n.x, to.y - n.y)
94
122
  changed = true
95
123
  }
@@ -109,9 +137,6 @@ export function settleBoard(model: WorldModel, remote: ReadonlySet<string>, touc
109
137
  * fit them. Members of 2+ teams keep their positions.
110
138
  */
111
139
  export function tidyMembers(model: WorldModel): void {
112
- const PAD = 28
113
- const GAP_X = 16
114
- const GAP_Y = 14
115
140
  for (const [name, rect] of model.allFrames()) {
116
141
  const sole = model.teamMemberIds(name)
117
142
  .map(id => model.getNode(id))
@@ -105,6 +105,7 @@ export interface PlanningView {
105
105
  pointerDown(ev: SeamPointer): void
106
106
  pointerMove(ev: SeamPointer): void
107
107
  pointerUp(ev: SeamPointer): void
108
+ pointerCancel(ev: SeamPointer): void
108
109
  wheel(ev: SeamPointer): void
109
110
  key(ev: SeamKey): void
110
111
  keyUp(ev: SeamKey): void
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 撤销 for the board layout (frontend seat, 2026-10-29): with no status-bar
3
+ * hint for automatic moves (owner: 载入时避让挪了卡片,不需要在状态条提示),
4
+ * a wrong move must be one keystroke away from undone. Covers card drags,
5
+ * frame drags and resizes, keyboard nudges, 整理成员, 一键重排 and the
6
+ * load-time settle — positions and frame rects only. Team membership
7
+ * (入队 / 离队, host writes) is not undone here.
8
+ *
9
+ * capture() before an edit, commit(before) after it: only an edit that
10
+ * actually moved something already on the board is recorded, so a click,
11
+ * a pan or a marquee leaves the stack alone, and a card that arrived by
12
+ * poll mid-gesture is not counted as a move. revert(before) is for a
13
+ * gesture the OS cancelled (pointercancel): layout back, nothing recorded.
14
+ * @module nexus-stage/planning-undo
15
+ */
16
+ import type { FrameGeom, WorldModel } from './world'
17
+
18
+ export interface LayoutSnap {
19
+ readonly nodes: ReadonlyMap<string, { readonly x: number; readonly y: number }>
20
+ readonly frames: ReadonlyMap<string, FrameGeom>
21
+ }
22
+
23
+ /** Undo depth (older steps fall off). */
24
+ export const UNDO_LIMIT = 30
25
+
26
+ export function createLayoutUndo(model: WorldModel) {
27
+ const stack: LayoutSnap[] = []
28
+ const capture = (): LayoutSnap => ({
29
+ nodes: new Map(model.allNodes().map(n => [n.id, { x: n.x, y: n.y }])),
30
+ frames: new Map(model.allFrames().map(([name, f]) => [name, { ...f }])),
31
+ })
32
+ /** True when something present both before and now has moved or changed size. */
33
+ const moved = (before: LayoutSnap): boolean =>
34
+ model.allNodes().some(n => { const b = before.nodes.get(n.id); return b !== undefined && (b.x !== n.x || b.y !== n.y) }) ||
35
+ model.allFrames().some(([name, f]) => { const b = before.frames.get(name); return b !== undefined && (b.x !== f.x || b.y !== f.y || b.w !== f.w || b.h !== f.h) })
36
+ const restore = (snap: LayoutSnap): void => {
37
+ for (const n of model.allNodes()) {
38
+ const b = snap.nodes.get(n.id)
39
+ if (b !== undefined && (b.x !== n.x || b.y !== n.y)) model.dragNodes([n.id], b.x - n.x, b.y - n.y)
40
+ }
41
+ for (const [name, f] of model.allFrames()) {
42
+ const b = snap.frames.get(name)
43
+ if (b !== undefined && (b.x !== f.x || b.y !== f.y || b.w !== f.w || b.h !== f.h)) model.setFrame(name, { ...b })
44
+ }
45
+ }
46
+ return {
47
+ capture,
48
+ commit(before: LayoutSnap): void {
49
+ if (!moved(before)) return
50
+ stack.push(before)
51
+ if (stack.length > UNDO_LIMIT) stack.shift()
52
+ },
53
+ /** Restore the last recorded layout; false when there is nothing to undo. */
54
+ undo(): boolean {
55
+ const snap = stack.pop()
56
+ if (snap === undefined) return false
57
+ restore(snap)
58
+ return true
59
+ },
60
+ /** Put back `before` without recording a step (an abandoned gesture); true when something moved. */
61
+ revert(before: LayoutSnap): boolean {
62
+ if (!moved(before)) return false
63
+ restore(before)
64
+ return true
65
+ },
66
+ get depth(): number { return stack.length },
67
+ }
68
+ }