@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32
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 +4 -1
- package/README_zh.md +3 -0
- package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
- package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +445 -57
- package/lib/a2a-client.js +9 -4
- package/lib/auth-observer.js +254 -0
- package/lib/blob-store.js +210 -0
- package/lib/card.js +4 -2
- package/lib/cli-deposit.js +122 -7
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +133 -43
- package/lib/faces/canvas-notify.js +12 -3
- package/lib/faces/files.js +155 -0
- package/lib/faces/nodes.js +171 -0
- package/lib/faces/session-log.js +162 -0
- package/lib/faces/wire.js +206 -39
- package/lib/index.js +809 -1720
- package/lib/joined-store.js +23 -0
- package/lib/ledger-conservation.js +97 -0
- package/lib/legacy/teams-bridge-inbound.js +156 -0
- package/lib/node-bind.js +534 -0
- package/lib/node-github.js +72 -0
- package/lib/node-self.js +453 -0
- package/lib/node-store.js +484 -0
- package/lib/node-tasks.js +342 -0
- package/lib/origin-claim.js +246 -0
- package/lib/peer-directory.js +134 -0
- package/lib/receipt.js +26 -1
- package/lib/skill/SKILL.md +3 -1
- package/lib/skill/references/cli.md +82 -3
- package/lib/skill/references/collaboration.md +146 -73
- package/lib/skill/references/discovery.md +29 -0
- package/lib/skill/references/intake.md +107 -0
- package/lib/skill/references/router.md +96 -0
- package/lib/skill/references/setup.md +2 -1
- package/lib/task-auth.js +483 -0
- package/lib/task-ledger.js +324 -12
- package/lib/team-store.js +24 -1
- package/lib/tools/act.js +383 -0
- package/lib/tools/files.js +201 -0
- package/lib/tools/info.js +684 -0
- package/lib/types/a2a-client.d.ts +10 -4
- package/lib/types/auth-observer.d.ts +110 -0
- package/lib/types/blob-store.d.ts +83 -0
- package/lib/types/cli-deposit.d.ts +38 -0
- package/lib/types/faces/admin.d.ts +14 -2
- package/lib/types/faces/canvas-notify.d.ts +4 -1
- package/lib/types/faces/files.d.ts +53 -0
- package/lib/types/faces/nodes.d.ts +69 -0
- package/lib/types/faces/session-log.d.ts +44 -0
- package/lib/types/faces/wire.d.ts +50 -5
- package/lib/types/index.d.ts +27 -0
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/ledger-conservation.d.ts +77 -0
- package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
- package/lib/types/node-bind.d.ts +220 -0
- package/lib/types/node-github.d.ts +35 -0
- package/lib/types/node-self.d.ts +178 -0
- package/lib/types/node-store.d.ts +180 -0
- package/lib/types/node-tasks.d.ts +121 -0
- package/lib/types/origin-claim.d.ts +119 -0
- package/lib/types/peer-directory.d.ts +39 -0
- package/lib/types/receipt.d.ts +12 -0
- package/lib/types/task-auth.d.ts +271 -0
- package/lib/types/task-ledger.d.ts +143 -6
- package/lib/types/team-store.d.ts +9 -0
- package/lib/types/tools/act.d.ts +52 -0
- package/lib/types/tools/files.d.ts +38 -0
- package/lib/types/tools/info.d.ts +102 -0
- package/lib/types/types.d.ts +6 -0
- package/nexus-stage/src/canvas-ops.ts +17 -2
- package/nexus-stage/src/canvas-wire.ts +27 -19
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +41 -18
- package/nexus-stage/src/planning-member-handles.ts +34 -0
- package/nexus-stage/src/planning-mirror.ts +47 -3
- package/nexus-stage/src/planning-reachability.ts +28 -0
- package/nexus-stage/src/planning-types.ts +1 -1
- package/nexus-stage/src/planning-view.ts +30 -27
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-directory.ts +13 -0
- package/nexus-stage/src/workbench.css +3 -0
- package/nexus-stage/src/workbench.ts +5 -1
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
- package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
3
|
+
import type { Config } from '../index.ts';
|
|
4
|
+
import type { CardCacheEntry, InFlightRoute, RouteActivityEntry } from '../runtime.ts';
|
|
5
|
+
import type { A2aPeerCard } from '../types.ts';
|
|
6
|
+
import type { ZoneCardFetch } from '../zone.ts';
|
|
7
|
+
import type { A2aClient, CardFetchOutcome } from '../a2a-client.ts';
|
|
8
|
+
import type { CanvasStore } from '../canvas-store.ts';
|
|
9
|
+
import type { ExposureStore } from '../exposure-store.ts';
|
|
10
|
+
import type { IdempotencyStore } from '../idempotency-store.ts';
|
|
11
|
+
import type { JoinedSessions } from '../joined-store.ts';
|
|
12
|
+
import type { PeerStore } from '../peer-store.ts';
|
|
13
|
+
import type { TaskLedger } from '../task-ledger.ts';
|
|
14
|
+
import type { TeamMembershipStore } from '../team-store.ts';
|
|
15
|
+
/** One directory row shared with listDirectoryTeams (which stays in index.ts). */
|
|
16
|
+
export type DirectoryTeamRow = {
|
|
17
|
+
team: string;
|
|
18
|
+
session: string;
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
local?: boolean;
|
|
22
|
+
origin?: string;
|
|
23
|
+
workspace?: string;
|
|
24
|
+
via?: string;
|
|
25
|
+
legacy?: boolean; /** gh#106: the peer's last card fetch missed — this row is its kept card, not a live answer. */
|
|
26
|
+
unreachable?: boolean;
|
|
27
|
+
teams?: string[]; /** Internal S5 scope hint, stripped before the tool's public output. */
|
|
28
|
+
canvasMembers?: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
/** Everything the information-plane tools capture from apply(). */
|
|
31
|
+
export interface InfoToolsDeps {
|
|
32
|
+
readonly ctx: Context;
|
|
33
|
+
readonly config: Config;
|
|
34
|
+
readonly logger: ReturnType<Context['logger']>;
|
|
35
|
+
readonly session: string;
|
|
36
|
+
readonly lanIp: string;
|
|
37
|
+
readonly ownCardUrl: string;
|
|
38
|
+
readonly peerStore: PeerStore;
|
|
39
|
+
readonly taskLedger: TaskLedger;
|
|
40
|
+
readonly joinedSessions: JoinedSessions;
|
|
41
|
+
readonly teamMemberships: TeamMembershipStore;
|
|
42
|
+
readonly canvasStore: CanvasStore;
|
|
43
|
+
readonly exposureStore: ExposureStore;
|
|
44
|
+
readonly idempotencyStore: IdempotencyStore;
|
|
45
|
+
readonly cardCache: Map<string, CardCacheEntry>;
|
|
46
|
+
readonly inFlightRoutes: Map<number, InFlightRoute>;
|
|
47
|
+
readonly recentActivity: RouteActivityEntry[];
|
|
48
|
+
readonly teamAuditRing: {
|
|
49
|
+
at: number;
|
|
50
|
+
action: 'join' | 'leave' | 'archive-prune' | 'agent-less';
|
|
51
|
+
team: string;
|
|
52
|
+
id: string;
|
|
53
|
+
ok: boolean;
|
|
54
|
+
reason?: string;
|
|
55
|
+
}[];
|
|
56
|
+
readonly reportRing: {
|
|
57
|
+
at: number;
|
|
58
|
+
level: 'info' | 'warning' | 'error';
|
|
59
|
+
message: string;
|
|
60
|
+
session: string;
|
|
61
|
+
task?: string;
|
|
62
|
+
}[];
|
|
63
|
+
readonly sessionNodes: Map<string, Agent>;
|
|
64
|
+
readonly ownerAllowlist: readonly string[];
|
|
65
|
+
readonly ownerTeamScopeInfo: boolean;
|
|
66
|
+
readonly id8: (id: string) => string;
|
|
67
|
+
readonly sessionTeamOf: (agent: Agent) => string;
|
|
68
|
+
readonly nodeMetadataOf: (agent: Agent) => {
|
|
69
|
+
name: string;
|
|
70
|
+
description: string;
|
|
71
|
+
workspace?: string;
|
|
72
|
+
};
|
|
73
|
+
readonly canvasTeamOf: (name: string) => string;
|
|
74
|
+
/** gh#152: the session's canvas teams read through the local lens — a handle-stored membership of this session counts. */
|
|
75
|
+
readonly canvasTeamsOfSession: (id: string) => string[];
|
|
76
|
+
/** Resolve a handle against the current local/seeded-peer collision snapshot. */
|
|
77
|
+
readonly localHandleResolver: () => (ref: string) => string | undefined;
|
|
78
|
+
readonly memoizedCardFetch: () => ZoneCardFetch;
|
|
79
|
+
readonly mapBounded: <T, R>(items: readonly T[], limit: number, task: (item: T) => Promise<R>) => Promise<R[]>;
|
|
80
|
+
readonly SWEEP_CONCURRENCY: number;
|
|
81
|
+
readonly a2aJoinGateRefusal: (exec: {
|
|
82
|
+
agent?: {
|
|
83
|
+
id: unknown;
|
|
84
|
+
};
|
|
85
|
+
} | undefined) => void;
|
|
86
|
+
readonly observeAuthorization: (cause: string) => void;
|
|
87
|
+
readonly settlePeerCard: (peer: string, outcome: CardFetchOutcome) => A2aPeerCard | undefined;
|
|
88
|
+
readonly client: A2aClient;
|
|
89
|
+
readonly listDirectoryTeams: (expand: boolean, includeCanvas?: boolean) => Promise<DirectoryTeamRow[]>;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the five registrars. Call createInfoTools after its deps exist, then
|
|
93
|
+
* invoke each registrar at the tool's original registration position — the
|
|
94
|
+
* registry order the model sees must not change.
|
|
95
|
+
*/
|
|
96
|
+
export declare function createInfoTools(deps: InfoToolsDeps): {
|
|
97
|
+
registerWhoami: () => void;
|
|
98
|
+
registerTeams: () => void;
|
|
99
|
+
registerStatus: () => void;
|
|
100
|
+
registerTasks: () => void;
|
|
101
|
+
registerProbe: () => void;
|
|
102
|
+
};
|
package/lib/types/types.d.ts
CHANGED
|
@@ -129,6 +129,12 @@ export interface A2aRouteOk {
|
|
|
129
129
|
* receipt-owed (`trackOwedTask` skips it).
|
|
130
130
|
*/
|
|
131
131
|
readonly bridge?: 'native-teams';
|
|
132
|
+
/**
|
|
133
|
+
* gh#141: the peer refused the settlement the delivered message carried
|
|
134
|
+
* (its enforce verdict, `settlement: {status: 'refused'}` on the wire,
|
|
135
|
+
* #139) — the delivery landed, the debt it named stays owed there.
|
|
136
|
+
*/
|
|
137
|
+
readonly settlementRefused?: true;
|
|
132
138
|
}
|
|
133
139
|
/** Canonical `a2a_route` failure value. */
|
|
134
140
|
export interface A2aRouteError {
|
|
@@ -13,16 +13,17 @@
|
|
|
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
|
-
| { type: 'remove-team'; name: string }
|
|
26
|
+
| { type: 'remove-team'; name: string; /** set when the frame is a REMOTE peer's team: the owner dissolves it, through the relay. */ fleetPeer?: string }
|
|
26
27
|
/** `ops` comes from reorderOps() at emit time; the wire stays model-free. */
|
|
27
28
|
| { type: 'reorder'; team: string; ops: ReadonlyArray<RosterOp> }
|
|
28
29
|
/** Network-level join/leave (the session joins the A2A mesh) — distinct from canvas-team membership. */
|
|
@@ -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
|
@@ -154,28 +154,34 @@ const canvasWire = createCanvasWire({
|
|
|
154
154
|
onNotice: (kind, text) => planning.notice(kind, text),
|
|
155
155
|
})
|
|
156
156
|
|
|
157
|
+
/** One relayed op on a REMOTE peer's team. THIS host signs the envelope
|
|
158
|
+
* and forwards; the peer's link gate authorizes the write. */
|
|
159
|
+
async function fleetOp(peer: string, op: 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<boolean> {
|
|
160
|
+
const r = await controlFetch('/__dsh_a2a/fleet-canvas-op', {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'Content-Type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({ peer, op, name, ...(id !== undefined ? { id } : {}) }),
|
|
164
|
+
cache: 'no-store',
|
|
165
|
+
}).catch(() => undefined)
|
|
166
|
+
const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
|
|
167
|
+
if (body?.ok === true && op === 'remove' && body.removed === false) planning.notice('info', `属主那边已没有「${name}」,无需解散`)
|
|
168
|
+
if (body?.ok === true) return true
|
|
169
|
+
// Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
|
|
170
|
+
planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
|
|
157
174
|
function runCanvasAction(a: CanvasAction): Promise<boolean> {
|
|
158
|
-
// gh#41: a remote-frame drop forks to the fleet relay
|
|
159
|
-
//
|
|
175
|
+
// gh#41: a remote-frame drop forks to the fleet relay; so does dissolving
|
|
176
|
+
// a mirror frame (single owner + mirror: the owner's store is the truth).
|
|
160
177
|
if ((a.type === 'add-member' || a.type === 'remove-member') && a.fleetPeer !== undefined) {
|
|
178
|
+
const peer = a.fleetPeer
|
|
161
179
|
return (async () => {
|
|
162
|
-
for (const id of a.ids)
|
|
163
|
-
const r = await controlFetch('/__dsh_a2a/fleet-canvas-op', {
|
|
164
|
-
method: 'POST',
|
|
165
|
-
headers: { 'Content-Type': 'application/json' },
|
|
166
|
-
body: JSON.stringify({ peer: a.fleetPeer, op: a.type === 'add-member' ? 'add-member' : 'remove-member', name: a.team, id }),
|
|
167
|
-
cache: 'no-store',
|
|
168
|
-
}).catch(() => undefined)
|
|
169
|
-
const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
|
|
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
|
-
}
|
|
175
|
-
}
|
|
180
|
+
for (const id of a.ids) if (!(await fleetOp(peer, a.type, a.team, id))) return false
|
|
176
181
|
return true
|
|
177
182
|
})()
|
|
178
183
|
}
|
|
184
|
+
if (a.type === 'remove-team' && a.fleetPeer !== undefined) return fleetOp(a.fleetPeer, 'remove', a.name)
|
|
179
185
|
switch (a.type) {
|
|
180
186
|
case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
|
|
181
187
|
case 'add-member': return canvasWire.addMembers(a.team, a.ids)
|
|
@@ -200,6 +206,9 @@ async function postNetworkJoin(action: 'join' | 'leave', id: string): Promise<bo
|
|
|
200
206
|
}
|
|
201
207
|
|
|
202
208
|
let lampState: LampState = 'idle'
|
|
209
|
+
// The nodes face reports effective scoped assemblies; a mounted registration alone does not prove visibility.
|
|
210
|
+
let nodePromptMissing = new Set<string>()
|
|
211
|
+
let nodePromptCheckedAt = 0
|
|
203
212
|
let canvasFace = false
|
|
204
213
|
let lastLayoutJson: string | undefined
|
|
205
214
|
/** True once any layout answer (saved doc or an explicit null) has been
|
|
@@ -375,13 +384,25 @@ async function poll(): Promise<void> {
|
|
|
375
384
|
const res = await controlFetch('/__dsh_a2a/state', { cache: 'no-store' })
|
|
376
385
|
if (!res.ok) throw new Error(`/__dsh_a2a/state ${res.status}`)
|
|
377
386
|
const body = (await res.json()) as StateBody
|
|
387
|
+
if (Date.now() - nodePromptCheckedAt >= 15_000) {
|
|
388
|
+
nodePromptCheckedAt = Date.now()
|
|
389
|
+
try {
|
|
390
|
+
const nodes = await controlFetch('/__dsh_a2a/nodes', { cache: 'no-store' })
|
|
391
|
+
if (!nodes.ok) throw new Error(`nodes ${nodes.status}`)
|
|
392
|
+
const view = await nodes.json() as { bindings?: Array<{ session: string; mounted: unknown; effective: { core: boolean; mutable: boolean } | null }> }
|
|
393
|
+
nodePromptMissing = new Set((view.bindings ?? [])
|
|
394
|
+
.filter(b => b.mounted !== null && b.effective !== null && (b.effective.core === false || b.effective.mutable === false))
|
|
395
|
+
.map(b => b.session))
|
|
396
|
+
} catch { nodePromptMissing.clear() } // unavailable is unknown, not confirmed missing
|
|
397
|
+
}
|
|
398
|
+
const shown = { ...body, sessions: (body.sessions ?? []).map(s => ({ ...s, ...(nodePromptMissing.has(s.id) ? { nodePromptMissing: true } : {}) })) }
|
|
378
399
|
clearFault()
|
|
379
400
|
// canvasFace first: bench.update reads board.available() to pick the
|
|
380
401
|
// mode, so assigning it afterwards held the very first paint in data
|
|
381
402
|
// mode (board display:none, 0px wide) until the next 3s poll — and the
|
|
382
403
|
// board's first reconcile measured a zero-width view.
|
|
383
404
|
canvasFace = body.canvas !== undefined
|
|
384
|
-
bench.update(
|
|
405
|
+
bench.update(shown)
|
|
385
406
|
|
|
386
407
|
// ── The layout answer precedes the first reconcile on purpose ──
|
|
387
408
|
// The board's first build reads `layoutDoc` for saved positions and
|
|
@@ -429,15 +450,17 @@ async function poll(): Promise<void> {
|
|
|
429
450
|
name: r.name,
|
|
430
451
|
via: r.via,
|
|
431
452
|
workspace: r.workspace,
|
|
453
|
+
...(r.unreachable === true ? { unreachable: true } : {}),
|
|
432
454
|
...(typeof r.origin === 'string' && r.origin !== '' ? { origin: r.origin } : {}),
|
|
433
455
|
...(Array.isArray(r.teams) && r.teams.length > 0 ? { teams: r.teams.filter((t): t is string => typeof t === 'string' && t !== '') } : {}),
|
|
434
456
|
}))
|
|
435
457
|
// gh#41: the peers' published canvas org rides the board (remote frames).
|
|
436
458
|
const peerOrg = (body.peerOrg as unknown[] ?? [])
|
|
437
|
-
.filter((r): r is { url?: unknown; org?: unknown } => r !== null && typeof r === 'object')
|
|
459
|
+
.filter((r): r is { url?: unknown; org?: unknown; unreachable?: unknown } => r !== null && typeof r === 'object')
|
|
438
460
|
.filter(r => typeof r.url === 'string' && Array.isArray(r.org))
|
|
439
461
|
.map(r => ({
|
|
440
462
|
url: r.url as string,
|
|
463
|
+
...(r.unreachable === true ? { unreachable: true } : {}),
|
|
441
464
|
org: (r.org as unknown[])
|
|
442
465
|
.filter((e): e is { name?: unknown; team?: unknown; members?: unknown } => e !== null && typeof e === 'object')
|
|
443
466
|
.filter(e => typeof e.team === 'string' && e.team !== '')
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Pure joined-local-handle projection for remote organization members. */
|
|
2
|
+
import type { RemoteTeamRow } from './federation'
|
|
3
|
+
import type { SessionLite } from './world'
|
|
4
|
+
|
|
5
|
+
type HandleSession = Pick<SessionLite, 'id' | 'team' | 'joined'>
|
|
6
|
+
|
|
7
|
+
/** Map only unique joined id8 handles that no remote directory row already owns. */
|
|
8
|
+
export function joinedLocalIdsByHandle(sessions: ReadonlyArray<HandleSession>, publishedHandles: ReadonlySet<string>): Map<string, string> {
|
|
9
|
+
const localIds = new Map<string, string>()
|
|
10
|
+
const ambiguous = new Set<string>()
|
|
11
|
+
for (const session of sessions) {
|
|
12
|
+
if (session.joined !== true) continue
|
|
13
|
+
const slash = session.team.lastIndexOf('/')
|
|
14
|
+
if (slash <= 0 || session.team.slice(slash + 1) !== session.id.replace(/^session-/, '').slice(0, 8)) continue
|
|
15
|
+
const handle = session.team
|
|
16
|
+
if (publishedHandles.has(handle) || ambiguous.has(handle)) continue
|
|
17
|
+
if (localIds.has(handle)) {
|
|
18
|
+
localIds.delete(handle)
|
|
19
|
+
ambiguous.add(handle)
|
|
20
|
+
} else {
|
|
21
|
+
localIds.set(handle, session.id)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return localIds
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Project a remote canvas member to its local card or stable synthetic card id. */
|
|
28
|
+
export function remoteMemberBoardId(member: string, localIdsByHandle: ReadonlyMap<string, string>, remoteTeams: ReadonlyArray<RemoteTeamRow> | undefined, peerUrl: string): string {
|
|
29
|
+
const localId = localIdsByHandle.get(member)
|
|
30
|
+
if (localId !== undefined) return localId
|
|
31
|
+
if (member.includes('/')) return `member-${member.replace(/[^a-z0-9-]/gi, '-')}`
|
|
32
|
+
const peerZone = remoteTeams?.find(row => row.via === peerUrl)?.team.split('/')[0] ?? 'dsh'
|
|
33
|
+
return `member-${peerZone}-${member.replace(/^session-/, '').slice(0, 8)}`
|
|
34
|
+
}
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* other host renders it as a MIRROR of that host's published card and edits
|
|
5
5
|
* it through the fleet relay. The frame head says so: 「镜像 · <owner host>」,
|
|
6
6
|
* and 「同步中…」 from the moment a relayed edit is sent until the owner
|
|
7
|
-
* answers (the relay
|
|
8
|
-
*
|
|
9
|
-
* is their owner.
|
|
7
|
+
* answers (the relay then re-reads the owner's card in the background, and
|
|
8
|
+
* the owner's own change ping backs it up). Local frames carry no marker —
|
|
9
|
+
* this host is their owner.
|
|
10
10
|
* @module nexus-stage/planning-mirror
|
|
11
11
|
*/
|
|
12
12
|
import { peerHostOf } from './federation'
|
|
@@ -29,3 +29,47 @@ export function mirrorRoute(ownerUrl: string | undefined, syncing: boolean): Mir
|
|
|
29
29
|
syncing,
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
type MirrorOrg = ReadonlyArray<{ readonly url: string; readonly org: ReadonlyArray<{ readonly name: string }> }>
|
|
34
|
+
|
|
35
|
+
/** After the owner accepted a dissolve, hide its team at most this long: a
|
|
36
|
+
* peer's late add-member re-creates the team on the owner, and then it is
|
|
37
|
+
* real again even though the owner's org never stopped listing it. */
|
|
38
|
+
export const DISSOLVE_HIDE_MS = 10_000
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 解散 on a mirror frame: the frame stays gone from the moment the dissolve
|
|
42
|
+
* is sent until the owner's published org stops listing the team (or
|
|
43
|
+
* DISSOLVE_HIDE_MS after the owner accepted). Without this, a poll that lands
|
|
44
|
+
* while the relay is in flight — or before the owner's card is re-read —
|
|
45
|
+
* draws the frame again (and keeps its saved rect for a later team of the
|
|
46
|
+
* same name). Keyed by owner AND name: two owners may both have a team X.
|
|
47
|
+
*/
|
|
48
|
+
export function createDissolveGuard(now: () => number = Date.now) {
|
|
49
|
+
const key = (ownerUrl: string, name: string): string => `${ownerUrl}\u0000${name}`
|
|
50
|
+
const dissolving = new Map<string, { url: string; name: string; acceptedAt?: number }>()
|
|
51
|
+
return {
|
|
52
|
+
/** Mark the dissolve and return the action carrying its owner (the relay fork). */
|
|
53
|
+
fork<A extends { type: 'remove-team'; name: string }>(act: A, ownerUrl: string): A & { fleetPeer: string } {
|
|
54
|
+
dissolving.set(key(ownerUrl, act.name), { url: ownerUrl, name: act.name })
|
|
55
|
+
return { ...act, fleetPeer: ownerUrl }
|
|
56
|
+
},
|
|
57
|
+
/** The owner answered: a refusal (or a failed channel) lets the frame back now, an acceptance starts the clock. */
|
|
58
|
+
settled(act: { type: string; name?: string; fleetPeer?: string }, ok: boolean): void {
|
|
59
|
+
if (act.type !== 'remove-team' || act.name === undefined || act.fleetPeer === undefined) return
|
|
60
|
+
const entry = dissolving.get(key(act.fleetPeer, act.name))
|
|
61
|
+
if (entry === undefined) return
|
|
62
|
+
if (ok) entry.acceptedAt = now()
|
|
63
|
+
else dissolving.delete(key(act.fleetPeer, act.name))
|
|
64
|
+
},
|
|
65
|
+
/** True while this owner's team is being (or has just been) dissolved here. */
|
|
66
|
+
hides(name: string, ownerUrl: string): boolean { return dissolving.has(key(ownerUrl, name)) },
|
|
67
|
+
/** Forget a dissolve once its owner's org no longer lists the team, or once its time is up. */
|
|
68
|
+
sweep(peerOrg: MirrorOrg): void {
|
|
69
|
+
for (const [k, e] of dissolving) {
|
|
70
|
+
const listed = peerOrg.find(row => row.url === e.url)?.org.some(t => t.name === e.name) === true
|
|
71
|
+
if (!listed || (e.acceptedAt !== undefined && now() - e.acceptedAt > DISSOLVE_HIDE_MS)) dissolving.delete(k)
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Project the peer's last missed card read onto existing remote cards and frames. */
|
|
2
|
+
import { peerHostOf, type RemoteTeamRow } from './federation'
|
|
3
|
+
|
|
4
|
+
export const UNREACHABLE_HINT = '对端未应答(显示的是最后已知状态)'
|
|
5
|
+
|
|
6
|
+
export function unreachableRefs(input: {
|
|
7
|
+
remoteTeams?: ReadonlyArray<RemoteTeamRow>
|
|
8
|
+
peerOrg?: ReadonlyArray<{ url: string; unreachable?: boolean }>
|
|
9
|
+
}): { hosts: ReadonlySet<string>; members: ReadonlySet<string> } {
|
|
10
|
+
const hosts = new Set<string>()
|
|
11
|
+
for (const row of input.remoteTeams ?? []) if (row.unreachable === true && row.via) hosts.add(peerHostOf(row.via))
|
|
12
|
+
for (const row of input.peerOrg ?? []) if (row.unreachable === true) hosts.add(peerHostOf(row.url))
|
|
13
|
+
const members = new Set<string>()
|
|
14
|
+
for (const row of input.remoteTeams ?? []) if (row.via && hosts.has(peerHostOf(row.via))) members.add(`member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`)
|
|
15
|
+
return { hosts, members }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A mirror keeps its last known contents visible but warns until its owner responds again. */
|
|
19
|
+
export function showFrameReachability(frame: HTMLElement, lost: boolean): void {
|
|
20
|
+
frame.classList.toggle('is-unreachable', lost)
|
|
21
|
+
const existing = frame.querySelector('.p-frame-unreachable')
|
|
22
|
+
if (lost && existing === null) {
|
|
23
|
+
const hint = document.createElement('span')
|
|
24
|
+
hint.className = 'p-frame-unreachable'
|
|
25
|
+
hint.textContent = UNREACHABLE_HINT
|
|
26
|
+
frame.appendChild(hint)
|
|
27
|
+
} else if (!lost) existing?.remove()
|
|
28
|
+
}
|
|
@@ -26,7 +26,7 @@ export interface PlanningInput {
|
|
|
26
26
|
remoteTeams?: ReadonlyArray<RemoteTeamRow>
|
|
27
27
|
/** gh#41 fleet planning: each tracked peer's published canvas org —
|
|
28
28
|
* rendered as REMOTE team frames (read + cross-host regulation). */
|
|
29
|
-
peerOrg?: ReadonlyArray<{ url: string; org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }> }>
|
|
29
|
+
peerOrg?: ReadonlyArray<{ url: string; unreachable?: boolean; org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }> }>
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** Screen-width class a viewport is remembered under (window width, not board width — collapsing the rail must not switch buckets). */
|