@nelsonlongxiang/dsh-open-a2a-net 0.6.32 → 0.6.34
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 +12 -3
- package/README_zh.md +12 -3
- package/assets/nexusDist/assets/index-D2BGWOeU.js +9 -0
- package/assets/nexusDist/assets/index-z33lHtLw.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +5 -2
- package/lib/a2a-client.js +9 -2
- package/lib/auth-observer.js +1 -1
- package/lib/canvas-store.js +50 -6
- package/lib/card.js +2 -1
- package/lib/cli-home.js +44 -0
- package/lib/client/A2aControl.js +1 -1
- package/lib/client.js +65 -65
- package/lib/client.js.map +1 -1
- package/lib/direct-forward.js +61 -0
- package/lib/faces/admin.js +84 -9
- package/lib/faces/wire.js +57 -16
- package/lib/index.js +118 -67
- package/lib/layout-store.js +3 -2
- package/lib/outbound-scope.js +25 -0
- package/lib/queue-guard.js +37 -0
- package/lib/roster-op.js +104 -0
- package/lib/skill/references/cli.md +6 -5
- package/lib/skill/references/pitfalls.md +1 -4
- package/lib/skill/references/router.md +1 -1
- package/lib/task-auth.js +52 -3
- package/lib/task-ledger.js +4 -1
- package/lib/team-store.js +49 -1
- package/lib/tools/info.js +19 -3
- package/lib/types/a2a-client.d.ts +1 -1
- package/lib/types/auth-observer.d.ts +2 -2
- package/lib/types/canvas-store.d.ts +14 -1
- package/lib/types/cli-home.d.ts +24 -0
- package/lib/types/client/A2aControl.d.ts +1 -1
- package/lib/types/direct-forward.d.ts +32 -0
- package/lib/types/faces/admin.d.ts +9 -1
- package/lib/types/faces/wire.d.ts +13 -4
- package/lib/types/index.d.ts +4 -0
- package/lib/types/outbound-scope.d.ts +21 -0
- package/lib/types/queue-guard.d.ts +32 -0
- package/lib/types/roster-op.d.ts +21 -0
- package/lib/types/runtime.d.ts +1 -1
- package/lib/types/task-auth.d.ts +17 -2
- package/lib/types/task-ledger.d.ts +6 -1
- package/lib/types/team-store.d.ts +7 -0
- package/lib/types/types.d.ts +8 -0
- package/nexus-stage/src/canvas-ops.ts +2 -1
- package/nexus-stage/src/cli-home-picker.ts +42 -0
- package/nexus-stage/src/layout-doc.ts +6 -4
- package/nexus-stage/src/main.ts +36 -4
- package/nexus-stage/src/planning-card.ts +61 -0
- package/nexus-stage/src/planning-frame-hue.ts +13 -0
- package/nexus-stage/src/planning-legend.ts +1 -0
- package/nexus-stage/src/planning-menus.ts +63 -12
- package/nexus-stage/src/planning-roster.ts +15 -0
- package/nexus-stage/src/planning-types.ts +1 -1
- package/nexus-stage/src/planning-view.ts +43 -59
- package/nexus-stage/src/planning.css +19 -0
- package/nexus-stage/src/workbench.css +4 -0
- package/nexus-stage/src/workbench.ts +24 -5
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +4 -2
- package/assets/nexusDist/assets/index-D5H8R8de.js +0 -9
- package/assets/nexusDist/assets/index-D9GYZyGZ.css +0 -1
package/lib/types/task-auth.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The admission basis (closed set, design §2.2). */
|
|
2
2
|
export type Via = 'host-face' | 'joined-session' | 'canvas-member' | 'peer-org' | 'cli-deposit' | 'open';
|
|
3
3
|
/** The exact rule that admitted a caller (closed set, design §2.2). */
|
|
4
|
-
export type AdmissionRule = 'scope-off' | 'host-face' | 'local-exempt' | 'roster-shared' | 'canvas-shared' | 'remote-ref-samezone' | 'remote-ref-foreign' | 'peer-org' | 'node-fact' | 'cli-deposit';
|
|
4
|
+
export type AdmissionRule = 'scope-off' | 'host-face' | 'local-exempt' | 'roster-shared' | 'canvas-shared' | 'remote-ref-samezone' | 'remote-ref-foreign' | 'remote-ref-cli' | 'peer-org' | 'node-fact' | 'cli-deposit';
|
|
5
5
|
/** Every rule's basis — the mapping the authorization record stores beside the rule. */
|
|
6
6
|
export declare const VIA_OF: Readonly<Record<AdmissionRule, Via>>;
|
|
7
7
|
/**
|
|
@@ -49,6 +49,15 @@ export interface AdmissionRecords {
|
|
|
49
49
|
peerOrgs(): Iterable<readonly PeerOrgEntry[]>;
|
|
50
50
|
liveNodeIds(): readonly string[];
|
|
51
51
|
cliDeposited(handle: string): boolean;
|
|
52
|
+
/** An operator-pinned CLI home, absent for ambiguous/unlinked CLI refs. */
|
|
53
|
+
cliHomeOf?(handle: string): string | undefined;
|
|
54
|
+
}
|
|
55
|
+
/** Bound signer evidence and a fresh full-card online check, never caller self-description. */
|
|
56
|
+
export interface RemoteCliProof {
|
|
57
|
+
readonly origin: string;
|
|
58
|
+
readonly viaUrl: string;
|
|
59
|
+
readonly bound: boolean;
|
|
60
|
+
readonly online: boolean;
|
|
52
61
|
}
|
|
53
62
|
/**
|
|
54
63
|
* W1/W2 shared-team admission core: a same-zone zone/id8 caller passes when
|
|
@@ -73,7 +82,9 @@ export declare function joinedFaceAdmission(r: AdmissionRecords, callerSession:
|
|
|
73
82
|
*/
|
|
74
83
|
export declare function localCandidateAdmission(r: AdmissionRecords, callerSession: string | undefined, targetTeam?: string): Admission;
|
|
75
84
|
/** Inbound admission for the peer faces (/a2a/direct, /a2a/log): caller_session is the contract. */
|
|
76
|
-
export declare function callerAdmission(r: AdmissionRecords, caller: string, targetTeam?: string): Admission;
|
|
85
|
+
export declare function callerAdmission(r: AdmissionRecords, caller: string, targetTeam?: string, cliProof?: RemoteCliProof): Admission;
|
|
86
|
+
/** Revalidate only mutable operator authority, not already-verified birth-time identity or liveness. */
|
|
87
|
+
export declare function recheckRemoteCliAuthorization(r: AdmissionRecords, auth: TaskAuthorization): Admission;
|
|
77
88
|
/**
|
|
78
89
|
* The inputs of the allowlist version (design §2.3): only operator-controlled
|
|
79
90
|
* records the admission rules really read. The peer-card canvasOrg is left
|
|
@@ -87,6 +98,7 @@ export interface AllowlistInputs {
|
|
|
87
98
|
readonly roster: readonly (readonly [handle: string, team: string])[];
|
|
88
99
|
readonly canvas: readonly (readonly [team: string, members: readonly string[]])[];
|
|
89
100
|
readonly cli: readonly string[];
|
|
101
|
+
readonly cliHome?: readonly (readonly [handle: string, origin: string])[];
|
|
90
102
|
readonly links: readonly string[];
|
|
91
103
|
}
|
|
92
104
|
/**
|
|
@@ -125,6 +137,8 @@ export interface TaskAuthorization {
|
|
|
125
137
|
};
|
|
126
138
|
readonly ingress: 'wire' | 'local';
|
|
127
139
|
readonly via: Via;
|
|
140
|
+
/** Signed courier host's card URL for a remote CLI, distinct from the closed-set admission via. */
|
|
141
|
+
readonly carrier_url?: string;
|
|
128
142
|
readonly admission_rule: AdmissionRule;
|
|
129
143
|
/** Every shared team that held (cache-owed ones as `peer-org:<team>`). */
|
|
130
144
|
readonly basis: readonly string[];
|
|
@@ -152,6 +166,7 @@ export interface AuthorizationFacts {
|
|
|
152
166
|
readonly caller: string;
|
|
153
167
|
readonly origin: string;
|
|
154
168
|
};
|
|
169
|
+
readonly carrierUrl?: string;
|
|
155
170
|
readonly ingress: 'wire' | 'local';
|
|
156
171
|
readonly allowlistVersion: string;
|
|
157
172
|
readonly target: string;
|
|
@@ -63,7 +63,7 @@ export type ArchiveEnd = 'receipt' | 'echo' | 'abandoned' | 'cancelled' | 'evict
|
|
|
63
63
|
* same-host seats could not be told apart.
|
|
64
64
|
*/
|
|
65
65
|
export interface Sender {
|
|
66
|
-
readonly kind: 'session' | 'host' | 'bridge';
|
|
66
|
+
readonly kind: 'session' | 'host' | 'bridge' | 'cli';
|
|
67
67
|
/**
|
|
68
68
|
* A session's `<zone>/<id8>` node team, the host process label, or the
|
|
69
69
|
* bridge submitter. Group senders by `kind:id`, never by `id` alone: a host
|
|
@@ -89,6 +89,7 @@ export declare const SENDER_ID_CAP = 256;
|
|
|
89
89
|
export declare function parseSender(raw: unknown): Sender | undefined;
|
|
90
90
|
/** #139: what an outbound row is born with besides its sender. */
|
|
91
91
|
export interface OutboundBirth {
|
|
92
|
+
readonly forwarded?: true;
|
|
92
93
|
/** The local-candidate admission (ingress local); kept only on a row whose peer is 'local'. */
|
|
93
94
|
readonly auth?: TaskAuthorization;
|
|
94
95
|
readonly peerKey?: string;
|
|
@@ -124,6 +125,8 @@ export interface TaskRecord {
|
|
|
124
125
|
readonly brief?: string;
|
|
125
126
|
/** #114 S3: the real sender of an outbound row (absent on inbound rows and rows persisted before the field). */
|
|
126
127
|
readonly sender?: Sender;
|
|
128
|
+
/** Home host forwarded an online CLI request to a peer. */
|
|
129
|
+
readonly forwarded?: true;
|
|
127
130
|
/**
|
|
128
131
|
* #114 S2: the immutable authorization the row was born with — an inbound
|
|
129
132
|
* row's wire admission, or (#139) an outbound row's local-candidate
|
|
@@ -183,6 +186,8 @@ export interface ArchivedRecord {
|
|
|
183
186
|
readonly receiptExpected?: false;
|
|
184
187
|
/** #114 S3: copied from the owed row (absent on inbound rows and rows archived before the field). */
|
|
185
188
|
readonly sender?: Sender;
|
|
189
|
+
/** Home host forwarded an online CLI request to a peer. */
|
|
190
|
+
readonly forwarded?: true;
|
|
186
191
|
/** #114 S2: carried over from the live row unchanged. */
|
|
187
192
|
readonly auth?: TaskAuthorization;
|
|
188
193
|
/** #139: carried over from the live row unchanged (a late receipt is judged against them). */
|
|
@@ -51,6 +51,13 @@ export declare class TeamMembershipStore {
|
|
|
51
51
|
add(session: string, team: string): void;
|
|
52
52
|
/** Retract one membership; a no-op when absent. */
|
|
53
53
|
remove(session: string, team: string): void;
|
|
54
|
+
/** One atomic roster replacement for the owner's workbench gesture. A failed disk write changes no in-memory membership. */
|
|
55
|
+
replaceTeam(team: string, members: readonly string[]): {
|
|
56
|
+
ok: true;
|
|
57
|
+
} | {
|
|
58
|
+
ok: false;
|
|
59
|
+
error: string;
|
|
60
|
+
};
|
|
54
61
|
/** Drop every declaration of one session (a left or archived node). */
|
|
55
62
|
dropSession(session: string): void;
|
|
56
63
|
/**
|
package/lib/types/types.d.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface A2aSessionTeamInfo {
|
|
|
76
76
|
readonly name: string;
|
|
77
77
|
/** One-line excerpt of the session's newest user or assistant text. */
|
|
78
78
|
readonly description: string;
|
|
79
|
+
/** CLI deposit status, explicitly online/offline on full host cards; absent on ordinary sessions. */
|
|
80
|
+
readonly offline?: boolean;
|
|
79
81
|
/** The session's working directory, when the publishing node shares it. */
|
|
80
82
|
readonly workspace?: string;
|
|
81
83
|
}
|
|
@@ -105,6 +107,8 @@ export interface ZoneRecord {
|
|
|
105
107
|
}
|
|
106
108
|
/** Canonical `a2a_route` success value. */
|
|
107
109
|
export interface A2aRouteOk {
|
|
110
|
+
/** Actual member face returned by a forwarded peer delivery. */
|
|
111
|
+
readonly deliveredTo?: string;
|
|
108
112
|
readonly ok: true;
|
|
109
113
|
readonly team: string;
|
|
110
114
|
/** Reply text the remote team produced. */
|
|
@@ -129,6 +133,8 @@ export interface A2aRouteOk {
|
|
|
129
133
|
* receipt-owed (`trackOwedTask` skips it).
|
|
130
134
|
*/
|
|
131
135
|
readonly bridge?: 'native-teams';
|
|
136
|
+
/** The peer's explicit receipt contract, when provided. */
|
|
137
|
+
readonly receipt?: 'none' | 'routed' | 'mailbox';
|
|
132
138
|
/**
|
|
133
139
|
* gh#141: the peer refused the settlement the delivered message carried
|
|
134
140
|
* (its enforce verdict, `settlement: {status: 'refused'}` on the wire,
|
|
@@ -138,6 +144,8 @@ export interface A2aRouteOk {
|
|
|
138
144
|
}
|
|
139
145
|
/** Canonical `a2a_route` failure value. */
|
|
140
146
|
export interface A2aRouteError {
|
|
147
|
+
/** A forwarded receipt never promises another receipt, including uncertain waits. */
|
|
148
|
+
readonly receipt?: 'none';
|
|
141
149
|
readonly ok: false;
|
|
142
150
|
/** Peer or transport failure text. */
|
|
143
151
|
readonly error: string;
|
|
@@ -19,7 +19,7 @@ import type { LayoutDoc } from './layout-doc'
|
|
|
19
19
|
export type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
20
20
|
|
|
21
21
|
/** The five user-facing write actions (one per design.md §3.3 row), plus the two network join/leave actions the panel-slim migration moved into the planning view. */
|
|
22
|
-
export type CanvasAction =
|
|
22
|
+
export type CanvasAction = (
|
|
23
23
|
| { type: 'create-team'; name: string; ids: readonly string[]; /** true when the model had no such team before this action; NOT server creation provenance and never authority to delete on failure. */ created?: boolean }
|
|
24
24
|
| { type: 'add-member'; team: string; ids: readonly string[]; /** gh#41: set when the target frame is a REMOTE peer's team — the wire routes the op through the host fleet relay. */ fleetPeer?: string }
|
|
25
25
|
| { type: 'remove-member'; team: string; ids: readonly string[]; /** gh#41: see add-member. */ fleetPeer?: string }
|
|
@@ -29,6 +29,7 @@ export type CanvasAction =
|
|
|
29
29
|
/** Network-level join/leave (the session joins the A2A mesh) — distinct from canvas-team membership. */
|
|
30
30
|
| { type: 'join-network'; id: string }
|
|
31
31
|
| { type: 'leave-network'; id: string }
|
|
32
|
+
) & { roster?: boolean }
|
|
32
33
|
|
|
33
34
|
/** Reverts exactly what one applyAction did (team-scoped). */
|
|
34
35
|
export type Undo = () => void
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Explicit operator choice only; this form never auto-selects or relaxes home admission. */
|
|
2
|
+
export interface CliHomeOptions { ok: boolean; homes?: string[]; unreadable?: number; held?: boolean; error?: string }
|
|
3
|
+
export function createCliHomePicker(read: () => Promise<CliHomeOptions>, save: (home: string) => Promise<boolean>): HTMLFormElement {
|
|
4
|
+
const form = document.createElement('form')
|
|
5
|
+
form.className = 'wb-cli-home'
|
|
6
|
+
const readButton = document.createElement('button')
|
|
7
|
+
readButton.type = 'button'; readButton.textContent = '指定 home'; readButton.className = 'wb-member-submit'
|
|
8
|
+
const select = document.createElement('select')
|
|
9
|
+
select.className = 'wb-member-pick'; select.setAttribute('aria-label', '选择 CLI home 主机'); select.hidden = true
|
|
10
|
+
const submit = document.createElement('button')
|
|
11
|
+
submit.type = 'submit'; submit.textContent = '确认 home'; submit.className = 'wb-member-submit'; submit.hidden = true; submit.disabled = true
|
|
12
|
+
const note = document.createElement('div')
|
|
13
|
+
note.className = 'wb-hint'; note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite')
|
|
14
|
+
note.textContent = 'home 未确定;点击指定 home 现读已链接主机名片。'
|
|
15
|
+
let busy = false
|
|
16
|
+
readButton.addEventListener('click', async () => {
|
|
17
|
+
if (busy) return
|
|
18
|
+
busy = true; readButton.disabled = true; submit.disabled = true; note.textContent = '正在现读主机名片…'
|
|
19
|
+
try {
|
|
20
|
+
const result = await read()
|
|
21
|
+
if (!result.ok) throw Error(result.error ?? '候选读取失败')
|
|
22
|
+
if (result.held) { readButton.hidden = true; select.hidden = true; submit.hidden = true; select.replaceChildren(); note.textContent = '本机持有,无需指定'; return }
|
|
23
|
+
select.replaceChildren(new Option('请选择 home 主机…', '', true, true))
|
|
24
|
+
for (const home of result.homes ?? []) select.appendChild(new Option(home, home))
|
|
25
|
+
select.hidden = false; submit.hidden = false; readButton.textContent = '刷新候选'
|
|
26
|
+
const count = result.homes?.length ?? 0
|
|
27
|
+
note.textContent = (result.unreadable ? '有 ' + result.unreadable + ' 台对端名片读不到;' : '') + (count > 1 ? '多台已链接主机列出该句柄,请显式选择。' : count === 1 ? '找到 1 台已链接主机;仍需你显式确认。' : '当前没有已链接且列出该句柄的可选主机。')
|
|
28
|
+
} catch (error) { note.textContent = '读取失败:' + String(error); select.hidden = true; submit.hidden = true }
|
|
29
|
+
finally { busy = false; readButton.disabled = false }
|
|
30
|
+
})
|
|
31
|
+
select.addEventListener('change', () => { submit.disabled = busy || select.value === '' })
|
|
32
|
+
form.addEventListener('submit', async event => {
|
|
33
|
+
event.preventDefault()
|
|
34
|
+
if (busy || select.value === '') return
|
|
35
|
+
busy = true; readButton.disabled = true; select.disabled = true; submit.disabled = true
|
|
36
|
+
try { note.textContent = await save(select.value) ? 'home 已指定:' + select.value : '指定失败:主机可能已离线或不再列出该句柄,请刷新后重选。' }
|
|
37
|
+
catch (error) { note.textContent = '指定失败:' + String(error) }
|
|
38
|
+
finally { busy = false; readButton.disabled = false; select.disabled = false; submit.disabled = select.value === '' }
|
|
39
|
+
})
|
|
40
|
+
form.append(readButton, select, submit, note)
|
|
41
|
+
return form
|
|
42
|
+
}
|
|
@@ -66,11 +66,13 @@ function clampRect(raw: unknown): LayoutRect | undefined {
|
|
|
66
66
|
* Normalize an untrusted layout document exactly as the host does:
|
|
67
67
|
* version!==1 rejects the whole doc; non-finite coordinates drop the
|
|
68
68
|
* point; coordinates round and clamp to ±COORD_LIMIT; scale clamps to
|
|
69
|
-
* [SCALE_MIN, SCALE_MAX]; node keys cap at 128 chars; frame
|
|
70
|
-
*
|
|
69
|
+
* [SCALE_MIN, SCALE_MAX]; node keys cap at 128 chars; canvas frame names cap at 40,
|
|
70
|
+
* roster:<name> reserves seven extra characters; rects need w>0 and h>0; caps are
|
|
71
71
|
* first-wins in insertion order - entries beyond are silently dropped,
|
|
72
72
|
* never refused (an overflow renders, it just does not persist).
|
|
73
73
|
*/
|
|
74
|
+
const validFrame = (name: string): boolean => name !== '' && !name.includes('/') && (name.startsWith('roster:') ? name.length > 7 && name.length <= 47 : name.length <= 40)
|
|
75
|
+
|
|
74
76
|
export function clampDoc(raw: unknown): LayoutDoc | undefined {
|
|
75
77
|
if (raw === null || typeof raw !== 'object') return undefined
|
|
76
78
|
const rec = raw as Record<string, unknown>
|
|
@@ -96,7 +98,7 @@ export function clampDoc(raw: unknown): LayoutDoc | undefined {
|
|
|
96
98
|
const frames: Record<string, LayoutRect> = {}
|
|
97
99
|
if (rec.frames !== null && typeof rec.frames === 'object') {
|
|
98
100
|
for (const [name, rect] of Object.entries(rec.frames as Record<string, unknown>)) {
|
|
99
|
-
if (
|
|
101
|
+
if (!validFrame(name)) continue
|
|
100
102
|
const r = clampRect(rect)
|
|
101
103
|
if (r !== undefined) frames[name] = r
|
|
102
104
|
if (Object.keys(frames).length >= LAYOUT_FRAME_CAP) break
|
|
@@ -127,7 +129,7 @@ export function buildLayoutDoc(
|
|
|
127
129
|
}
|
|
128
130
|
const docFrames: Record<string, LayoutRect> = {}
|
|
129
131
|
for (const [name, r] of frames) {
|
|
130
|
-
if (
|
|
132
|
+
if (!validFrame(name)) continue
|
|
131
133
|
const w = clamp(r.w)
|
|
132
134
|
const h = clamp(r.h)
|
|
133
135
|
if (w <= 0 || h <= 0) continue
|
package/nexus-stage/src/main.ts
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
import { createFaultReporter } from './fault'
|
|
10
10
|
import { createWorkbench, type StateBody } from './workbench'
|
|
11
11
|
import { createPlanningView } from './planning-view'
|
|
12
|
+
import { rosterFrames, rosterNameOf } from './planning-roster'
|
|
12
13
|
import { createSaveLoop, type LampState } from './layout-wire'
|
|
13
14
|
import { createCanvasWire } from './canvas-wire'
|
|
14
|
-
import type
|
|
15
|
+
import { actionTeam, type CanvasAction } from './canvas-ops'
|
|
15
16
|
import './workbench.css'
|
|
16
17
|
|
|
17
18
|
const app = document.getElementById('app')!
|
|
@@ -171,6 +172,24 @@ async function fleetOp(peer: string, op: 'add-member' | 'remove-member' | 'remov
|
|
|
171
172
|
return false
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
async function rosterAction(a: CanvasAction, name: string): Promise<boolean> {
|
|
176
|
+
const post = async (body: Record<string, unknown>): Promise<boolean> => {
|
|
177
|
+
const reply = await controlFetch('/__dsh_a2a/roster-op', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ...body }) }).catch(() => undefined)
|
|
178
|
+
const result = reply !== undefined ? await reply.json().catch(() => undefined) as { ok?: boolean; error?: string } | undefined : undefined
|
|
179
|
+
if (reply?.ok && result?.ok === true) return true
|
|
180
|
+
planning.notice('error', `roster 操作失败:${String(result?.error ?? (reply ? `HTTP ${reply.status}` : '无响应'))}`)
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
if (a.type === 'create-team') return post({ op: 'create', ids: a.ids })
|
|
184
|
+
if (a.type === 'remove-team') return post({ op: 'dissolve' })
|
|
185
|
+
if (a.type === 'add-member' || a.type === 'remove-member') {
|
|
186
|
+
for (const id of a.ids) if (!(await post({ op: a.type, id }))) return false
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
planning.notice('error', 'roster 不支持该操作')
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
|
|
174
193
|
function runCanvasAction(a: CanvasAction): Promise<boolean> {
|
|
175
194
|
// gh#41: a remote-frame drop forks to the fleet relay; so does dissolving
|
|
176
195
|
// a mirror frame (single owner + mirror: the owner's store is the truth).
|
|
@@ -182,6 +201,12 @@ function runCanvasAction(a: CanvasAction): Promise<boolean> {
|
|
|
182
201
|
})()
|
|
183
202
|
}
|
|
184
203
|
if (a.type === 'remove-team' && a.fleetPeer !== undefined) return fleetOp(a.fleetPeer, 'remove', a.name)
|
|
204
|
+
// Only a trusted registry roster frame or an explicit create choice may trigger a local roster write.
|
|
205
|
+
if (a.roster === true) {
|
|
206
|
+
const roster = rosterNameOf(actionTeam(a))
|
|
207
|
+
if (roster !== undefined) return rosterAction(a, roster)
|
|
208
|
+
planning.notice('error', '无效的 roster 框名'); return Promise.resolve(false)
|
|
209
|
+
}
|
|
185
210
|
switch (a.type) {
|
|
186
211
|
case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
|
|
187
212
|
case 'add-member': return canvasWire.addMembers(a.team, a.ids)
|
|
@@ -267,14 +292,21 @@ const bench = createWorkbench({
|
|
|
267
292
|
// The only reach primitive is the inline 快派 form: it works for local
|
|
268
293
|
// session teams, canvas teams and remote rows alike, and never leaves
|
|
269
294
|
// this page.
|
|
270
|
-
async
|
|
295
|
+
async cliHomeOptions(name, id) {
|
|
296
|
+
try {
|
|
297
|
+
const res = await controlFetch('/__dsh_a2a/canvas', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cli-home-options', name, id }) })
|
|
298
|
+
const body = await res.json() as { ok: boolean; homes?: string[]; unreadable?: number; held?: boolean; error?: string }
|
|
299
|
+
return { ...body, ok: res.ok && body.ok === true }
|
|
300
|
+
} catch (error) { return { ok: false, error: String(error) } }
|
|
301
|
+
},
|
|
302
|
+
async canvasOp(action, name, id, home) {
|
|
271
303
|
// Same control route the planning board's wire uses; the inspector's
|
|
272
304
|
// member writes are one id at a time, so no serial fan-out needed.
|
|
273
305
|
try {
|
|
274
306
|
const res = await controlFetch('/__dsh_a2a/canvas', {
|
|
275
307
|
method: 'POST',
|
|
276
308
|
headers: { 'content-type': 'application/json' },
|
|
277
|
-
body: JSON.stringify({ action, name, id }),
|
|
309
|
+
body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }) }),
|
|
278
310
|
})
|
|
279
311
|
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
|
|
280
312
|
if (res.ok && body.ok !== false) return true
|
|
@@ -440,7 +472,7 @@ async function poll(): Promise<void> {
|
|
|
440
472
|
// SessionLite needs `joined: boolean` (workbench rows carry `joined?`):
|
|
441
473
|
// tri-state → boolean without losing the unjoined node-list population.
|
|
442
474
|
const allSessions = (body.sessions ?? []).map(s => ({ ...s, joined: s.joined === true }))
|
|
443
|
-
const teams = body.canvas?.teams ?? []
|
|
475
|
+
const teams = [...(body.canvas?.teams ?? []), ...rosterFrames(body.registry?.teams ?? [], body.zone ?? '')]
|
|
444
476
|
const peers = (body.peers ?? []).filter(p => p !== null && typeof p === 'object' && typeof (p as { url?: unknown }).url === 'string')
|
|
445
477
|
const inFlight = (body.inFlight ?? []).filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { peer?: unknown }).peer === 'string' && Number.isFinite((r as { startedAt?: unknown }).startedAt))
|
|
446
478
|
const remoteTeams = (body.remote ?? [])
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board node card: the DOM skeleton and the right-hand chip. Extracted from planning-view so the card's static
|
|
3
|
+
* structure reads in one place and the view stays under its M1 module-size cap (1507 lines; it reached 1529 with
|
|
4
|
+
* the 工作中/空闲 chip). Pure DOM helpers — no view state; renderNodes still decides what each card shows.
|
|
5
|
+
* @module nexus-stage/planning-card
|
|
6
|
+
*/
|
|
7
|
+
import type { PlanningSession } from './planning-types'
|
|
8
|
+
import type { WorldNode } from './world'
|
|
9
|
+
|
|
10
|
+
/** The card's 工作中/空闲 chip text; empty when the host reported no status (older host, cold or remote node). */
|
|
11
|
+
export function workLabel(work: Pick<PlanningSession, 'status' | 'queued'> | undefined): string {
|
|
12
|
+
if (work?.status === undefined) return ''
|
|
13
|
+
const base = work.status === 'running' ? '工作中' : '空闲'
|
|
14
|
+
return typeof work.queued === 'number' && work.queued > 0 ? `${base} · 排队 ${String(work.queued)}` : base
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A fresh `.p-node` card: title row (dot + text), second row (description + 工作中/空闲 chip), and the right-hand chip. */
|
|
18
|
+
export function createCardElement(id: string): HTMLDivElement {
|
|
19
|
+
const el = document.createElement('div')
|
|
20
|
+
el.className = 'p-node'
|
|
21
|
+
el.dataset.id = id
|
|
22
|
+
el.tabIndex = 0
|
|
23
|
+
const nm = document.createElement('div')
|
|
24
|
+
nm.className = 'nm'
|
|
25
|
+
const dot = document.createElement('span')
|
|
26
|
+
dot.className = 'dot'
|
|
27
|
+
const name = document.createElement('span')
|
|
28
|
+
name.className = 'nm-text'
|
|
29
|
+
nm.append(dot, name)
|
|
30
|
+
const sub = document.createElement('div')
|
|
31
|
+
sub.className = 'sub mono'
|
|
32
|
+
const prio = document.createElement('span')
|
|
33
|
+
prio.className = 'prio mono'
|
|
34
|
+
// Second line: the description, with the 工作中/空闲 chip at its right end — the title keeps its full width.
|
|
35
|
+
const work = document.createElement('span')
|
|
36
|
+
work.className = 'work'
|
|
37
|
+
const subRow = document.createElement('div')
|
|
38
|
+
subRow.className = 'sub-row'
|
|
39
|
+
subRow.append(sub, work)
|
|
40
|
+
el.append(nm, subRow, prio)
|
|
41
|
+
return el
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The right-hand chip. Peer cards show the link badge (gh#20 W-A: linked green / stranger gray, then the
|
|
46
|
+
* verified tick/cross; an absent link field from an older host renders the neutral 'peer'); session cards show
|
|
47
|
+
* their first membership priority `P<n>`. Writes only on change.
|
|
48
|
+
*/
|
|
49
|
+
export function paintPrio(prioEl: HTMLElement, n: WorldNode, info: { link?: 'linked' | 'stranger'; verified?: boolean } | undefined): void {
|
|
50
|
+
if (n.remote === true) {
|
|
51
|
+
const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
|
|
52
|
+
if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
|
|
53
|
+
const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
|
|
54
|
+
const badge = (info?.link ?? 'peer') + mark
|
|
55
|
+
if (prioEl.textContent !== badge) prioEl.textContent = badge
|
|
56
|
+
} else {
|
|
57
|
+
if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
|
|
58
|
+
const prioText = n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : ''
|
|
59
|
+
if (prioEl.textContent !== prioText) prioEl.textContent = prioText
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Stable team hue shared by canvas and roster frames. */
|
|
2
|
+
import { FRAME_HUES } from './tokens'
|
|
3
|
+
|
|
4
|
+
export function frameHue(name: string): { line: string; bg: string } {
|
|
5
|
+
let h = 0
|
|
6
|
+
for (let i = 0; i < name.length; i++) h = ((h * 31 + name.charCodeAt(i)) & 0xfffffff) >>> 0
|
|
7
|
+
const rgb = FRAME_HUES[h % FRAME_HUES.length]!
|
|
8
|
+
const line = "#" + rgb.toString(16).padStart(6, "0")
|
|
9
|
+
const r = (rgb >> 16) & 0xff
|
|
10
|
+
const g = (rgb >> 8) & 0xff
|
|
11
|
+
const b = rgb & 0xff
|
|
12
|
+
return { line, bg: `rgba(${r},${g},${b},0.05)` }
|
|
13
|
+
}
|
|
@@ -27,6 +27,7 @@ export function createLegend(): HTMLDetailsElement {
|
|
|
27
27
|
}
|
|
28
28
|
row(false, '成员边(星形,框→成员)')
|
|
29
29
|
row(true, '跨队多属(虚线)')
|
|
30
|
+
row(true, 'roster 网络队框(双线边框)')
|
|
30
31
|
row(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
|
|
31
32
|
row(true, '联邦线(→peer)', 'var(--federal)')
|
|
32
33
|
legend.append(summary, rows)
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @module nexus-stage/planning-menus
|
|
9
9
|
*/
|
|
10
10
|
import { reorderOps, type CanvasAction } from './canvas-ops'
|
|
11
|
+
import { rosterFrameName, rosterFrameTitle } from './planning-roster'
|
|
11
12
|
import type { PlanningSession, PlanningTeam } from './planning-types'
|
|
12
13
|
import type { WorldModel } from './world'
|
|
13
14
|
|
|
@@ -22,6 +23,8 @@ export interface MenusDeps {
|
|
|
22
23
|
readonly model: WorldModel
|
|
23
24
|
/** Canvas teams from the latest poll. */
|
|
24
25
|
teams(): ReadonlyArray<PlanningTeam>
|
|
26
|
+
joined(id: string): boolean
|
|
27
|
+
isRoster(frame: string): boolean
|
|
25
28
|
/** The card or frame-head element a menu re-focuses on close. */
|
|
26
29
|
anchorFor(target: MenuTarget): HTMLElement | null
|
|
27
30
|
emit(a: CanvasAction): void
|
|
@@ -45,6 +48,7 @@ export interface PlanningMenus {
|
|
|
45
48
|
|
|
46
49
|
export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
47
50
|
const { root, netBtn, signal, model } = d
|
|
51
|
+
const titleOf = (frame: string): string => d.isRoster(frame) ? rosterFrameTitle(frame) : frame
|
|
48
52
|
|
|
49
53
|
// ── 建队:selection → 命名对话框 → create-team ──
|
|
50
54
|
function selectedByY(): string[] {
|
|
@@ -87,6 +91,46 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
87
91
|
title.id = 'p-dialog-title'
|
|
88
92
|
title.className = 'p-dialog-title'
|
|
89
93
|
title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
|
|
94
|
+
// Team type: a visible two-way toggle (a bare <select> read as a label and roster went unnoticed).
|
|
95
|
+
const kind = { value: 'canvas' as 'canvas' | 'roster' }
|
|
96
|
+
const kindGroup = document.createElement('div')
|
|
97
|
+
kindGroup.className = 'p-dialog-kind'
|
|
98
|
+
kindGroup.setAttribute('role', 'radiogroup')
|
|
99
|
+
kindGroup.setAttribute('aria-label', '团队类型')
|
|
100
|
+
const kindHint = document.createElement('div')
|
|
101
|
+
kindHint.className = 'p-dialog-hint'
|
|
102
|
+
const KIND_HINT = {
|
|
103
|
+
canvas: 'canvas 队:画布上的协作组,可以加入远端成员。',
|
|
104
|
+
roster: 'roster 网络队:只收本机已入网的会话;队名会写进属主的精确允许名单。',
|
|
105
|
+
} as const
|
|
106
|
+
const kindButtons = (['canvas', 'roster'] as const).map(value => {
|
|
107
|
+
const b = document.createElement('button')
|
|
108
|
+
b.type = 'button'
|
|
109
|
+
b.dataset.kind = value
|
|
110
|
+
b.setAttribute('role', 'radio')
|
|
111
|
+
b.textContent = value === 'canvas' ? 'canvas 队' : 'roster 网络队'
|
|
112
|
+
b.addEventListener('click', () => setKind(value))
|
|
113
|
+
kindGroup.appendChild(b)
|
|
114
|
+
return b
|
|
115
|
+
})
|
|
116
|
+
function setKind(value: 'canvas' | 'roster'): void {
|
|
117
|
+
kind.value = value
|
|
118
|
+
for (const b of kindButtons) {
|
|
119
|
+
const on = b.dataset.kind === value
|
|
120
|
+
b.setAttribute('aria-checked', String(on))
|
|
121
|
+
b.tabIndex = on ? 0 : -1
|
|
122
|
+
}
|
|
123
|
+
kindHint.textContent = KIND_HINT[value]
|
|
124
|
+
err.textContent = ''
|
|
125
|
+
}
|
|
126
|
+
// Arrow keys move between the two options (radiogroup pattern).
|
|
127
|
+
kindGroup.addEventListener('keydown', (ev) => {
|
|
128
|
+
const e = ev as KeyboardEvent
|
|
129
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return
|
|
130
|
+
e.preventDefault()
|
|
131
|
+
setKind(kind.value === 'canvas' ? 'roster' : 'canvas')
|
|
132
|
+
kindButtons.find(b => b.dataset.kind === kind.value)?.focus()
|
|
133
|
+
})
|
|
90
134
|
const input = document.createElement('input')
|
|
91
135
|
input.className = 'p-dialog-input'
|
|
92
136
|
input.maxLength = 40
|
|
@@ -103,7 +147,8 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
103
147
|
cancelBtn.type = 'button'
|
|
104
148
|
cancelBtn.textContent = '取消'
|
|
105
149
|
row.append(okBtn, cancelBtn)
|
|
106
|
-
|
|
150
|
+
setKind('canvas')
|
|
151
|
+
panel.append(title, kindGroup, kindHint, input, err, row)
|
|
107
152
|
wrap.appendChild(panel)
|
|
108
153
|
root.appendChild(wrap)
|
|
109
154
|
|
|
@@ -115,25 +160,27 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
115
160
|
}
|
|
116
161
|
const nameError = (): string | null => {
|
|
117
162
|
const name = input.value.trim()
|
|
118
|
-
if (name === '' || name.length > 40 || name.includes('/') || /^\d+$/.test(name))
|
|
119
|
-
|
|
120
|
-
|
|
163
|
+
if (name === '' || name.length > 40 || name.includes('/') || name.startsWith('roster:') || (kind.value === 'roster' && name.includes('*')) || /^\d+$/.test(name)) return '队名需 1..40 字,不含“/”、通配符或保留前缀,不为纯数字'
|
|
164
|
+
if (kind.value === 'roster' && model.getFrame(name) !== undefined) return '同名 canvas 队已存在,不能创建 roster 队'
|
|
165
|
+
if (kind.value === 'roster' && model.getFrame(rosterFrameName(name)) !== undefined && !d.isRoster(rosterFrameName(name))) return 'roster 框名已被 canvas 或镜像队占用'
|
|
166
|
+
if (kind.value === 'canvas' && model.getFrame(rosterFrameName(name)) !== undefined) return '同名 roster 队已存在,不能创建 canvas 队'
|
|
167
|
+
if (kind.value === 'roster' && ids.some(id => model.getNode(id)?.remote === true || !d.joined(id))) return 'roster 仅接受本机已入网的会话'
|
|
121
168
|
return null
|
|
122
169
|
}
|
|
123
170
|
const confirm = (): void => {
|
|
124
171
|
const problem = nameError()
|
|
125
172
|
if (problem !== null) { err.textContent = problem; input.focus(); return }
|
|
126
|
-
const name = input.value.trim()
|
|
173
|
+
const name = kind.value === 'roster' ? rosterFrameName(input.value.trim()) : input.value.trim()
|
|
127
174
|
close()
|
|
128
175
|
if (model.getFrame(name) !== undefined) {
|
|
129
176
|
// Duplicate name: host create is idempotent-ok, but the optimistic
|
|
130
177
|
// create-team would WIPE the existing roster and overwrite its saved
|
|
131
178
|
// frame rect. Join instead — same wire result, nothing destroyed.
|
|
132
|
-
d.notice('info', `已加入现有团队「${name}」`)
|
|
179
|
+
d.notice('info', `已加入现有团队「${kind.value === 'roster' ? rosterFrameTitle(name) : name}」`)
|
|
133
180
|
d.emit({ type: 'add-member', team: name, ids })
|
|
134
181
|
return
|
|
135
182
|
}
|
|
136
|
-
d.emit({ type: 'create-team', name, ids, created: true })
|
|
183
|
+
d.emit({ type: 'create-team', name, ids, created: true, ...(kind.value === 'roster' ? { roster: true } : {}) })
|
|
137
184
|
}
|
|
138
185
|
okBtn.addEventListener('click', confirm)
|
|
139
186
|
cancelBtn.addEventListener('click', close)
|
|
@@ -149,7 +196,7 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
149
196
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
|
|
150
197
|
if (e.key !== 'Tab') return
|
|
151
198
|
e.preventDefault()
|
|
152
|
-
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, button'))
|
|
199
|
+
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, select, button:not([tabindex="-1"])'))
|
|
153
200
|
const idx = focusables.indexOf(document.activeElement as HTMLElement)
|
|
154
201
|
const next = e.shiftKey
|
|
155
202
|
? focusables[(idx - 1 + focusables.length) % focusables.length]
|
|
@@ -252,7 +299,7 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
252
299
|
el.setAttribute('aria-label', '团队操作')
|
|
253
300
|
let items: HTMLButtonElement[] = []
|
|
254
301
|
if (target.kind === 'frame') {
|
|
255
|
-
items = [menuItem(`解散团队「${target.name}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
|
|
302
|
+
items = [menuItem(`解散团队「${titleOf(target.name)}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
|
|
256
303
|
} else {
|
|
257
304
|
const id = target.id
|
|
258
305
|
if (level === 'root') {
|
|
@@ -278,13 +325,17 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
278
325
|
for (const t of d.teams()) {
|
|
279
326
|
if (level === 'join') {
|
|
280
327
|
const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? true
|
|
281
|
-
|
|
328
|
+
const allowed = !d.isRoster(t.name) || (d.joined(id) && model.getNode(id)?.remote !== true)
|
|
329
|
+
const item = menuItem(already ? `${titleOf(t.name)}(已加入)` : titleOf(t.name), !already && allowed, () => d.emit({ type: 'add-member', team: t.name, ids: [id] }))
|
|
330
|
+
if (!allowed) item.title = 'roster 只接受本机已入网会话'
|
|
331
|
+
items.push(item)
|
|
282
332
|
} else if (level === 'leave') {
|
|
283
333
|
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
284
|
-
items.push(menuItem(t.name, member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
|
|
334
|
+
items.push(menuItem(titleOf(t.name), member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
|
|
285
335
|
} else {
|
|
286
336
|
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
287
|
-
|
|
337
|
+
if (d.isRoster(t.name)) continue
|
|
338
|
+
items.push(menuItem(titleOf(t.name), member, () => {
|
|
288
339
|
const current = model.teamMemberIds(t.name)
|
|
289
340
|
const desired = [id, ...current.filter(x => x !== id)]
|
|
290
341
|
d.emit({ type: 'reorder', team: t.name, ops: reorderOps(current, desired) })
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Roster frames use an independent layout namespace beside canvas frames. */
|
|
2
|
+
import type { PlanningTeam } from './planning-types'
|
|
3
|
+
|
|
4
|
+
export const ROSTER_PREFIX = 'roster:'
|
|
5
|
+
export const rosterNameOf = (frame: string): string | undefined => frame.startsWith(ROSTER_PREFIX) ? frame.slice(ROSTER_PREFIX.length) : undefined
|
|
6
|
+
export const rosterFrameName = (name: string): string => ROSTER_PREFIX + name
|
|
7
|
+
export const rosterFrameTitle = (frame: string): string => { const name = rosterNameOf(frame); return name === undefined ? frame : `网络队 · roster · ${name}` }
|
|
8
|
+
export function rosterFrames(rows: readonly { team: string; members: readonly string[] }[], zone: string): PlanningTeam[] {
|
|
9
|
+
return rows.flatMap(row => {
|
|
10
|
+
if (zone === '' || !row.team.startsWith(zone + '/') || !Array.isArray(row.members)) return []
|
|
11
|
+
const name = row.team.slice(zone.length + 1)
|
|
12
|
+
if (name === '' || name.length > 40 || name.includes('/') || name.startsWith(ROSTER_PREFIX) || name.includes('*')) return []
|
|
13
|
+
return [{ name: rosterFrameName(name), team: row.team, kind: 'roster' as const, members: row.members.filter(id => typeof id === 'string' && id !== '').map(id => ({ id })) }]
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -12,7 +12,7 @@ import type { SessionLite, TeamLite } from './world'
|
|
|
12
12
|
|
|
13
13
|
/** Minimal session/team shapes consumed here (topology rows satisfy them). */
|
|
14
14
|
export interface PlanningSession extends SessionLite {}
|
|
15
|
-
export interface PlanningTeam extends TeamLite {}
|
|
15
|
+
export interface PlanningTeam extends TeamLite { kind?: 'roster' }
|
|
16
16
|
|
|
17
17
|
export interface PlanningInput {
|
|
18
18
|
sessions: ReadonlyArray<PlanningSession>
|