@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.
- package/README.md +2 -0
- package/README_zh.md +2 -0
- package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
- package/assets/nexusDist/assets/index-jm-cFAhB.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +624 -624
- package/lib/canvas-store.js +21 -1
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +12 -1
- package/lib/faces/canvas-notify.js +138 -0
- package/lib/faces/wire.js +669 -0
- package/lib/flood-guard.js +106 -0
- package/lib/index.js +69 -663
- package/lib/skill/SKILL.md +1 -0
- package/lib/skill/references/collaboration.md +376 -0
- package/lib/task-ledger.js +34 -4
- package/lib/types/canvas-store.d.ts +10 -1
- package/lib/types/faces/admin.d.ts +2 -1
- package/lib/types/faces/canvas-notify.d.ts +74 -0
- package/lib/types/faces/wire.d.ts +171 -0
- package/lib/types/flood-guard.d.ts +29 -0
- package/lib/types/runtime.d.ts +1 -0
- package/lib/types/task-ledger.d.ts +9 -1
- package/nexus-stage/src/canvas-ops.ts +16 -1
- package/nexus-stage/src/canvas-wire.ts +27 -19
- package/nexus-stage/src/main.ts +5 -1
- package/nexus-stage/src/planning-edges.ts +26 -0
- package/nexus-stage/src/planning-mirror.ts +31 -0
- package/nexus-stage/src/planning-overlap.ts +36 -11
- package/nexus-stage/src/planning-types.ts +1 -0
- package/nexus-stage/src/planning-undo.ts +68 -0
- package/nexus-stage/src/planning-view.ts +61 -37
- package/nexus-stage/src/planning.css +2 -0
- package/nexus-stage/src/world.ts +8 -20
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-BqepxJ7E.css +0 -1
- 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 {};
|
package/lib/types/runtime.d.ts
CHANGED
|
@@ -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
|
|
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`
|
|
11
|
-
*
|
|
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
|
-
* -
|
|
15
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
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
|
}
|
package/nexus-stage/src/main.ts
CHANGED
|
@@ -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)
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|