@nelsonlongxiang/dsh-open-a2a-net 0.6.33 → 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 +5 -2
- package/README_zh.md +5 -2
- 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/lib/a2a-client.js +9 -2
- package/lib/auth-observer.js +1 -1
- package/lib/canvas-store.js +44 -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 +41 -5
- package/lib/faces/wire.js +55 -16
- package/lib/index.js +110 -64
- package/lib/outbound-scope.js +25 -0
- package/lib/queue-guard.js +37 -0
- package/lib/skill/references/cli.md +5 -4
- package/lib/skill/references/pitfalls.md +1 -4
- package/lib/task-auth.js +52 -3
- package/lib/task-ledger.js +4 -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 +10 -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 +8 -1
- package/lib/types/faces/wire.d.ts +12 -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/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/types.d.ts +8 -0
- package/nexus-stage/src/cli-home-picker.ts +42 -0
- package/nexus-stage/src/main.ts +9 -2
- package/nexus-stage/src/planning-card.ts +61 -0
- package/nexus-stage/src/planning-menus.ts +42 -7
- package/nexus-stage/src/planning-view.ts +14 -32
- package/nexus-stage/src/planning.css +17 -0
- package/nexus-stage/src/workbench.css +4 -0
- package/nexus-stage/src/workbench.ts +22 -5
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +2 -1
- package/assets/nexusDist/assets/index-BACVZe0j.css +0 -1
- package/assets/nexusDist/assets/index-Bbxnpf2p.js +0 -9
package/lib/tools/info.js
CHANGED
|
@@ -480,12 +480,14 @@ export function createInfoTools(deps) {
|
|
|
480
480
|
deadAt: { type: 'number' },
|
|
481
481
|
direction: { type: 'string', enum: ['inbound', 'outbound'] },
|
|
482
482
|
receiptExpected: { type: 'boolean' },
|
|
483
|
+
forwarded: { type: 'boolean' },
|
|
484
|
+
deliveredTo: { type: 'string' },
|
|
483
485
|
reason: { type: 'string', enum: ['fire-forget', 'unconsumed'] },
|
|
484
486
|
sender: {
|
|
485
487
|
type: 'object',
|
|
486
488
|
additionalProperties: false,
|
|
487
489
|
properties: {
|
|
488
|
-
kind: { type: 'string', required: true, enum: ['session', 'host', 'bridge'] },
|
|
490
|
+
kind: { type: 'string', required: true, enum: ['session', 'host', 'bridge', 'cli'] },
|
|
489
491
|
id: { type: 'string', required: true },
|
|
490
492
|
face: { type: 'string' },
|
|
491
493
|
},
|
|
@@ -506,11 +508,12 @@ export function createInfoTools(deps) {
|
|
|
506
508
|
resolvedAt: { type: 'number', required: true },
|
|
507
509
|
summary: { type: 'string' },
|
|
508
510
|
brief: { type: 'string' },
|
|
511
|
+
forwarded: { type: 'boolean' },
|
|
509
512
|
sender: {
|
|
510
513
|
type: 'object',
|
|
511
514
|
additionalProperties: false,
|
|
512
515
|
properties: {
|
|
513
|
-
kind: { type: 'string', required: true, enum: ['session', 'host', 'bridge'] },
|
|
516
|
+
kind: { type: 'string', required: true, enum: ['session', 'host', 'bridge', 'cli'] },
|
|
514
517
|
id: { type: 'string', required: true },
|
|
515
518
|
face: { type: 'string' },
|
|
516
519
|
},
|
|
@@ -566,7 +569,20 @@ export function createInfoTools(deps) {
|
|
|
566
569
|
const archiveAll = taskLedger.archive();
|
|
567
570
|
return {
|
|
568
571
|
ok: true,
|
|
569
|
-
|
|
572
|
+
// Project the declared fields only: ledger rows also carry internal audit facts (the #114 auth record,
|
|
573
|
+
// #139 peerKey/peerHost) the output schema does not admit — spreading the row failed every call (0.6.32+).
|
|
574
|
+
tasks: taskLedger.list().map(({ taskId, team, peer, startedAt, contextId, brief, status, deadAt, direction, receiptExpected, forwarded, deliveredTo, reason, sender }) => ({
|
|
575
|
+
taskId, team, peer, startedAt, status,
|
|
576
|
+
...(contextId !== undefined ? { contextId } : {}),
|
|
577
|
+
...(brief !== undefined ? { brief } : {}),
|
|
578
|
+
...(deadAt !== undefined ? { deadAt } : {}),
|
|
579
|
+
...(direction !== undefined ? { direction } : {}),
|
|
580
|
+
...(receiptExpected !== undefined ? { receiptExpected } : {}),
|
|
581
|
+
...(forwarded !== undefined ? { forwarded } : {}),
|
|
582
|
+
...(deliveredTo !== undefined ? { deliveredTo } : {}),
|
|
583
|
+
...(reason !== undefined ? { reason } : {}),
|
|
584
|
+
...(sender !== undefined ? { sender } : {}),
|
|
585
|
+
})),
|
|
570
586
|
archive: archiveAll.slice(0, 5).map(({ taskId, team, startedAt, resolvedAt, summary, brief, sender }) => ({ taskId, team, startedAt, resolvedAt, ...(summary !== undefined && summary !== '' ? { summary } : {}), ...(brief !== undefined ? { brief } : {}), ...(sender !== undefined ? { sender } : {}) })),
|
|
571
587
|
archivedTotal: archiveAll.length,
|
|
572
588
|
};
|
|
@@ -116,7 +116,7 @@ export declare class A2aClient {
|
|
|
116
116
|
* @param callerSession - caller label overriding the node's label (the calling session's node label).
|
|
117
117
|
* @returns the canonical route result (success or explicit failure).
|
|
118
118
|
*/
|
|
119
|
-
routeDirect(baseUrl: string, team: string, message: string, contextId?: string, signal?: AbortSignal, callerSession?: string, asyncMode?: boolean, taskIdFromCaller?: string, callbackAddress?: string): Promise<A2aRouteResult>;
|
|
119
|
+
routeDirect(baseUrl: string, team: string, message: string, contextId?: string, signal?: AbortSignal, callerSession?: string, asyncMode?: boolean, taskIdFromCaller?: string, callbackAddress?: string, guard?: string): Promise<A2aRouteResult>;
|
|
120
120
|
/**
|
|
121
121
|
* Query a peer's `/a2a/query` outcome surface (W7 slice 2): read-only
|
|
122
122
|
* lookup of one claimed task's settled outcome. The fingerprint is the
|
|
@@ -49,7 +49,7 @@ export interface JudgedRow {
|
|
|
49
49
|
* default: the birth record's `via`).
|
|
50
50
|
* @returns the affected rows, in ledger order; unaffected rows are omitted.
|
|
51
51
|
*/
|
|
52
|
-
export declare function judgeAffected(rows: readonly JudgedRow[], version: string, judge: (caller: string, target: string, ingress: 'wire' | 'local') => Admission, cacheResting?: (row: JudgedRow) => boolean): AffectedTask[];
|
|
52
|
+
export declare function judgeAffected(rows: readonly JudgedRow[], version: string, judge: (caller: string, target: string, ingress: 'wire' | 'local', auth: TaskAuthorization) => Admission, cacheResting?: (row: JudgedRow) => boolean): AffectedTask[];
|
|
53
53
|
/** What one {@link AuthObserver.observe} call needs from the host. */
|
|
54
54
|
export interface ObserveInput {
|
|
55
55
|
readonly version: string;
|
|
@@ -57,7 +57,7 @@ export interface ObserveInput {
|
|
|
57
57
|
readonly inputs: () => unknown;
|
|
58
58
|
readonly cause: string;
|
|
59
59
|
readonly rows: () => readonly JudgedRow[];
|
|
60
|
-
readonly judge: (caller: string, target: string, ingress: 'wire' | 'local') => Admission;
|
|
60
|
+
readonly judge: (caller: string, target: string, ingress: 'wire' | 'local', auth: TaskAuthorization) => Admission;
|
|
61
61
|
}
|
|
62
62
|
/**
|
|
63
63
|
* The persisted observer. One instance per host; every entry point is
|
|
@@ -22,6 +22,8 @@ export interface CanvasSnapshot {
|
|
|
22
22
|
readonly name: string;
|
|
23
23
|
readonly members: readonly string[];
|
|
24
24
|
}>;
|
|
25
|
+
/** Pinned CLI handle -> linked home origin (only while some canvas team holds the handle). */
|
|
26
|
+
readonly cliHome?: Readonly<Record<string, string>>;
|
|
25
27
|
}
|
|
26
28
|
/** Upper bounds: a canvas taxonomy, not a database. */
|
|
27
29
|
export declare const CANVAS_TEAM_CAP = 64;
|
|
@@ -50,6 +52,7 @@ export declare function validRemoteMemberRef(id: string): boolean;
|
|
|
50
52
|
export declare class CanvasStore {
|
|
51
53
|
/** Team entries in creation order; members in add order (routing priority). */
|
|
52
54
|
private teams;
|
|
55
|
+
private cliHome;
|
|
53
56
|
private readonly path;
|
|
54
57
|
private readonly listeners;
|
|
55
58
|
/**
|
|
@@ -74,6 +77,9 @@ export declare class CanvasStore {
|
|
|
74
77
|
hasTeam(name: string): boolean;
|
|
75
78
|
/** The named team's member ids in routing-priority order (a copy). */
|
|
76
79
|
membersOf(name: string): readonly string[];
|
|
80
|
+
/** Frozen, operator-linked CLI home; undefined means unknown or ambiguous. */
|
|
81
|
+
cliHomeOf(handle: string): string | undefined;
|
|
82
|
+
pinnedCliHomes(): ReadonlyArray<readonly [string, string]>;
|
|
77
83
|
/**
|
|
78
84
|
* The named team's remote member refs (zone-qualified team handles) in
|
|
79
85
|
* routing-priority order — the failover tier after the local live/cold
|
|
@@ -101,11 +107,14 @@ export declare class CanvasStore {
|
|
|
101
107
|
* @param name - the existing team name.
|
|
102
108
|
* @param id - the member session id.
|
|
103
109
|
*/
|
|
104
|
-
addMember(name: string, id: string
|
|
110
|
+
addMember(name: string, id: string, home?: string, options?: {
|
|
111
|
+
replaceHome?: boolean;
|
|
112
|
+
}): boolean;
|
|
105
113
|
/**
|
|
106
114
|
* Remove one member from one team (a no-op when absent).
|
|
107
115
|
*/
|
|
108
116
|
removeMember(name: string, id: string): boolean;
|
|
117
|
+
private forgetUnusedHome;
|
|
109
118
|
/**
|
|
110
119
|
* Drop one id from every team — the leave and archive paths: a session
|
|
111
120
|
* leaving the network keeps no canvas membership anywhere.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { A2aPeerCard } from './types.ts';
|
|
2
|
+
/** A verified, operator-linked host's full card at link time. */
|
|
3
|
+
export interface CliHomeCandidate {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly linked: boolean;
|
|
6
|
+
readonly card?: A2aPeerCard;
|
|
7
|
+
}
|
|
8
|
+
/** Canonical root HTTP(S) origin; address aliases remain distinct trust decisions. */
|
|
9
|
+
export declare function cliHomeOrigin(value: unknown): string | undefined;
|
|
10
|
+
export type CliHomeDecision = {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
readonly home?: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly ok: false;
|
|
15
|
+
readonly error: string;
|
|
16
|
+
};
|
|
17
|
+
/** Read-only projection of the same fresh evidence used by explicit home validation. */
|
|
18
|
+
export declare function cliHomeChoices(handle: string, candidates: readonly CliHomeCandidate[], locallyHeld?: boolean): {
|
|
19
|
+
homes: string[];
|
|
20
|
+
unreadable: number;
|
|
21
|
+
held?: true;
|
|
22
|
+
};
|
|
23
|
+
/** Count origins, not URL aliases: two card URLs on one host are one home. */
|
|
24
|
+
export declare function decideCliHome(handle: string, requested: unknown, candidates: readonly CliHomeCandidate[], locallyHeld?: boolean): CliHomeDecision;
|
|
@@ -7,7 +7,7 @@ export interface A2aSessionRow {
|
|
|
7
7
|
/** One routing outcome as the state route reports it. */
|
|
8
8
|
export interface A2aActivityRow {
|
|
9
9
|
readonly ts: number;
|
|
10
|
-
readonly dir: 'in' | 'out';
|
|
10
|
+
readonly dir: 'in' | 'out' | 'fwd';
|
|
11
11
|
readonly team: string;
|
|
12
12
|
readonly peer: string;
|
|
13
13
|
readonly ok: boolean;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type TaskLedger } from './task-ledger.ts';
|
|
2
|
+
import type { A2aPeerCard, A2aRouteResult } from './types.ts';
|
|
3
|
+
export interface ForwardRequest {
|
|
4
|
+
team: string;
|
|
5
|
+
message: string;
|
|
6
|
+
caller: string;
|
|
7
|
+
taskId: string;
|
|
8
|
+
contextId?: string;
|
|
9
|
+
noWait: boolean;
|
|
10
|
+
localCliProof: boolean;
|
|
11
|
+
guarded: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface DirectForwardDeps {
|
|
14
|
+
enabled(): boolean;
|
|
15
|
+
isolated(): boolean;
|
|
16
|
+
deposited(handle: string): boolean;
|
|
17
|
+
memberLinked(handle: string): boolean;
|
|
18
|
+
scope(handle: string, team: string): Promise<{
|
|
19
|
+
ok: true;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
error: string;
|
|
23
|
+
}>;
|
|
24
|
+
candidates(team: string, failures: string[], caller: string): Promise<{
|
|
25
|
+
urls: string[];
|
|
26
|
+
card(url: string): Promise<A2aPeerCard | undefined>;
|
|
27
|
+
}>;
|
|
28
|
+
dispatch(url: string, request: ForwardRequest, guard: string): Promise<A2aRouteResult>;
|
|
29
|
+
ledger: TaskLedger;
|
|
30
|
+
activity(team: string, peer: string, ok: boolean): void;
|
|
31
|
+
}
|
|
32
|
+
export declare function createDirectForward(d: DirectForwardDeps): (request: ForwardRequest) => Promise<A2aRouteResult>;
|
|
@@ -6,12 +6,13 @@ import type { Config } from '../index.ts';
|
|
|
6
6
|
import type { CardCacheEntry, InFlightRoute, RouteActivityEntry } from '../runtime.ts';
|
|
7
7
|
import { CanvasStore } from '../canvas-store.ts';
|
|
8
8
|
import { type CanvasNotifyDeps } from './canvas-notify.ts';
|
|
9
|
-
import type
|
|
9
|
+
import { type CliDepositStore } from '../cli-deposit.ts';
|
|
10
10
|
import type { ExposureStore } from '../exposure-store.ts';
|
|
11
11
|
import type { GroupStore } from '../group-store.ts';
|
|
12
12
|
import type { IdempotencyStore } from '../idempotency-store.ts';
|
|
13
13
|
import type { JoinedSessions } from '../joined-store.ts';
|
|
14
14
|
import type { LayoutStore } from '../layout-store.ts';
|
|
15
|
+
import type { CliHomeDecision } from '../cli-home.ts';
|
|
15
16
|
import type { PeerStore } from '../peer-store.ts';
|
|
16
17
|
import { type TaskLedger } from '../task-ledger.ts';
|
|
17
18
|
import type { TeamMembershipStore } from '../team-store.ts';
|
|
@@ -61,6 +62,12 @@ export interface AdminFaceDeps extends Pick<CanvasNotifyDeps, 'verifyOriginClaim
|
|
|
61
62
|
readonly cliDeposits: CliDepositStore;
|
|
62
63
|
readonly groupStore: GroupStore;
|
|
63
64
|
readonly canvasStore: CanvasStore;
|
|
65
|
+
readonly cliHomeForAdd: (handle: string, requested?: unknown) => Promise<CliHomeDecision>;
|
|
66
|
+
readonly cliHomeOptions: (handle: string) => Promise<{
|
|
67
|
+
homes: string[];
|
|
68
|
+
unreadable: number;
|
|
69
|
+
held?: true;
|
|
70
|
+
}>;
|
|
64
71
|
readonly layoutStore: LayoutStore;
|
|
65
72
|
readonly taskLedger: TaskLedger;
|
|
66
73
|
/** #114 S6: observe (lazily) and return the /state `authorization` block. */
|
|
@@ -14,13 +14,14 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
14
14
|
import type { Context } from '@deepseek-ai/cordis';
|
|
15
15
|
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
16
16
|
import type WebServer from '@deepseek-ai/dsh-host-webserver';
|
|
17
|
-
import type { A2aPeerCard } from '../types.ts';
|
|
17
|
+
import type { A2aPeerCard, A2aRouteResult } from '../types.ts';
|
|
18
|
+
import type { ForwardRequest } from '../direct-forward.ts';
|
|
18
19
|
import { CanvasStore } from '../canvas-store.ts';
|
|
19
|
-
import type
|
|
20
|
+
import { type CliDepositStore } from '../cli-deposit.ts';
|
|
20
21
|
import type { IdempotencyStore } from '../idempotency-store.ts';
|
|
21
22
|
import type { JoinedSessions } from '../joined-store.ts';
|
|
22
23
|
import { type TaskLedger } from '../task-ledger.ts';
|
|
23
|
-
import { type Admission, type SettlementRefusal, type Settler } from '../task-auth.ts';
|
|
24
|
+
import { type Admission, type RemoteCliProof, type SettlementRefusal, type Settler } from '../task-auth.ts';
|
|
24
25
|
import type { Config } from '../index.ts';
|
|
25
26
|
/**
|
|
26
27
|
* #139 (design §4.4): add `settlement: {status:'refused', reason, task_id}`
|
|
@@ -51,6 +52,7 @@ export type RouteOutcome = {
|
|
|
51
52
|
} | {
|
|
52
53
|
ok: false;
|
|
53
54
|
error: string;
|
|
55
|
+
localMiss?: true;
|
|
54
56
|
};
|
|
55
57
|
interface BridgeTeamsRegistryMirror {
|
|
56
58
|
listTeams(): Array<{
|
|
@@ -127,6 +129,10 @@ export interface WireDeps {
|
|
|
127
129
|
readonly verifyOriginClaim: (req: IncomingMessage, options?: {
|
|
128
130
|
readonly cli?: boolean;
|
|
129
131
|
}) => Promise<OriginClaimResult>;
|
|
132
|
+
readonly forwardDirect: (request: ForwardRequest) => Promise<A2aRouteResult>;
|
|
133
|
+
readonly remoteCliProof: (handle: string, claim: Extract<OriginClaimResult, {
|
|
134
|
+
ok: true;
|
|
135
|
+
}>) => Promise<RemoteCliProof | undefined>;
|
|
130
136
|
readonly callerLinkedToThisHost: (caller: string, callerUrl: string, callerCard: A2aPeerCard | undefined) => boolean;
|
|
131
137
|
/** This host's own card URL (read when a request lands; set once the web server settles). */
|
|
132
138
|
readonly ownCardUrl: string;
|
|
@@ -135,7 +141,7 @@ export interface WireDeps {
|
|
|
135
141
|
readonly secretEqual: (a: string, b: string) => boolean;
|
|
136
142
|
readonly a2aCallerRefusal: (caller: string, targetTeam?: string) => string | undefined;
|
|
137
143
|
/** #114 S2: the same decision with the rule and basis that admitted the caller. */
|
|
138
|
-
readonly a2aCallerAdmission: (caller: string, targetTeam?: string) => Admission;
|
|
144
|
+
readonly a2aCallerAdmission: (caller: string, targetTeam?: string, cliProof?: RemoteCliProof) => Admission;
|
|
139
145
|
/** #114 S2: this host's allowlist version right now (design §2.3). */
|
|
140
146
|
readonly allowlistVersionNow: () => string;
|
|
141
147
|
/** #114 S6: record the version if it moved and re-judge affected tasks (observe only). */
|
|
@@ -159,6 +165,8 @@ export interface WireDeps {
|
|
|
159
165
|
};
|
|
160
166
|
readonly taskLedger: TaskLedger;
|
|
161
167
|
readonly refuseIfFlooded: (caller: string, message: string, team: string, taskId: string, res: ServerResponse) => boolean;
|
|
168
|
+
/** gh#224: refuses a new delivery once the target already owes `targetQueueCap` receipts. */
|
|
169
|
+
readonly refuseIfQueueFull: (caller: string, message: string, team: string, taskId: string, mailbox: boolean, res: ServerResponse) => boolean;
|
|
162
170
|
/** Settles a receipt; returns the enforce refusal of the row its header names (#139), if any. */
|
|
163
171
|
readonly settleAndAnnounce: (message: string, settler?: Settler) => SettlementRefusal | undefined;
|
|
164
172
|
readonly touchWakeDemand: () => void;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -91,6 +91,8 @@ export interface Config {
|
|
|
91
91
|
* (which is left untouched). Inbound faces keep answering. Default false.
|
|
92
92
|
*/
|
|
93
93
|
readonly isolated: boolean;
|
|
94
|
+
/** Forward online local CLI deliveries one hop after a local miss. Default off. */
|
|
95
|
+
readonly forwardDirect: boolean;
|
|
94
96
|
/** Human-facing name published on the agent card. */
|
|
95
97
|
readonly agentName: string;
|
|
96
98
|
/**
|
|
@@ -258,6 +260,8 @@ export interface Config {
|
|
|
258
260
|
* v0.5.23 (async-stall): see the schema comment on asyncNudgeDelayMs.
|
|
259
261
|
*/
|
|
260
262
|
readonly asyncNudgeDelayMs: number;
|
|
263
|
+
/** gh#224: receipt-owing tasks one target may queue on this host before new deliveries are refused (0 = off). */
|
|
264
|
+
readonly targetQueueCap: number;
|
|
261
265
|
/**
|
|
262
266
|
* How long the state route serves the cold-row id set from cache before
|
|
263
267
|
* re-enumerating the persistence layer. The panel polls every 2s; the
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** One sender scope gate shared by model routing and home-host CLI forwarding. */
|
|
2
|
+
export interface OutboundScopeRecords {
|
|
3
|
+
readonly zone: string;
|
|
4
|
+
teamsOf(id: string): readonly string[];
|
|
5
|
+
membersOf(team: string): readonly string[];
|
|
6
|
+
id8(id: string): string;
|
|
7
|
+
canvasNames(): readonly string[];
|
|
8
|
+
canvasMembers(name: string): readonly string[];
|
|
9
|
+
canvasTeam(name: string): string;
|
|
10
|
+
peerOrgs(): Iterable<readonly {
|
|
11
|
+
team?: unknown;
|
|
12
|
+
members?: unknown;
|
|
13
|
+
}[]>;
|
|
14
|
+
warmPeers(): Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
export declare function outboundScope(r: OutboundScopeRecords, id: string, handle: string, team: string): Promise<{
|
|
17
|
+
ok: true;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
error: string;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** gh#224 per-target queue cap (owner 2026-11-03: "at most 3 queued"). The flood guard (gh#91)
|
|
2
|
+
* bounds a sender's RATE; a slow, steady sender can still pile a dozen unanswered tasks onto one
|
|
3
|
+
* busy session. This counts what the target already owes and refuses a NEW delivery once the
|
|
4
|
+
* count reaches the cap, visibly and before anything is steered or booked.
|
|
5
|
+
*
|
|
6
|
+
* Counted ("queued for T"): rows this host's ledger still holds pending for T that expect a
|
|
7
|
+
* receipt — inbound rows born at /a2a/direct (addressed to T, or delivered to T), and same-host
|
|
8
|
+
* routes (peer 'local') to T. Never refused: a receipt for a task this host's ledger holds
|
|
9
|
+
* (terminal; refusing one strands that row — a genuine receipt always returns to the host that
|
|
10
|
+
* booked its task, so a '[A2A receipt]' header naming an unknown id is ordinary traffic and is
|
|
11
|
+
* counted), and deliveries into a CLI mailbox (its own 64-mail bound applies). A canvas team is
|
|
12
|
+
* one target: a full queue refuses the team as addressed, remote members included.
|
|
13
|
+
*/
|
|
14
|
+
import type { ServerResponse } from 'node:http';
|
|
15
|
+
import type { IdempotencyStore } from './idempotency-store.ts';
|
|
16
|
+
export declare const WIRE_ERROR_QUEUE_FULL = -32009;
|
|
17
|
+
export declare const DEFAULT_TARGET_QUEUE_CAP = 3;
|
|
18
|
+
/** The ledger fields the count reads. */
|
|
19
|
+
export interface QueueRow {
|
|
20
|
+
readonly team: string;
|
|
21
|
+
readonly peer: string;
|
|
22
|
+
readonly status: string;
|
|
23
|
+
readonly direction?: string;
|
|
24
|
+
readonly receiptExpected?: boolean;
|
|
25
|
+
readonly deliveredTo?: string;
|
|
26
|
+
}
|
|
27
|
+
/** How many receipt-owing tasks this host holds pending for `team`. */
|
|
28
|
+
export declare function queuedFor(rows: readonly QueueRow[], team: string): number;
|
|
29
|
+
/** The refusal text when `team`'s queue is full, else undefined. `cap <= 0` disables the guard. */
|
|
30
|
+
export declare function queueFullRefusal(rows: readonly QueueRow[], team: string, message: string, cap: number, mailbox: boolean, ownTask: (taskId: string) => boolean): string | undefined;
|
|
31
|
+
/** Wire-face adapter in the flood guard's shape: a refused fresh key is FAILED, not stranded pending. */
|
|
32
|
+
export declare function createQueueGuard(rows: () => readonly QueueRow[], cap: () => number, ownTask: (taskId: string) => boolean, outcomes: Pick<IdempotencyStore, 'recordOutcome'>, recordActivity: (dir: 'in' | 'out', team: string, peer: string, ok: boolean) => void): (caller: string, message: string, team: string, taskId: string, mailbox: boolean, res: ServerResponse) => boolean;
|
package/lib/types/runtime.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { A2aPeerCard } from './types.ts';
|
|
|
11
11
|
*/
|
|
12
12
|
export interface RouteActivityEntry {
|
|
13
13
|
readonly ts: number;
|
|
14
|
-
readonly dir: 'in' | 'out';
|
|
14
|
+
readonly dir: 'in' | 'out' | 'fwd';
|
|
15
15
|
readonly team: string;
|
|
16
16
|
readonly peer: string;
|
|
17
17
|
readonly ok: boolean;
|
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). */
|
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;
|
|
@@ -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
|
+
}
|
package/nexus-stage/src/main.ts
CHANGED
|
@@ -292,14 +292,21 @@ const bench = createWorkbench({
|
|
|
292
292
|
// The only reach primitive is the inline 快派 form: it works for local
|
|
293
293
|
// session teams, canvas teams and remote rows alike, and never leaves
|
|
294
294
|
// this page.
|
|
295
|
-
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) {
|
|
296
303
|
// Same control route the planning board's wire uses; the inspector's
|
|
297
304
|
// member writes are one id at a time, so no serial fan-out needed.
|
|
298
305
|
try {
|
|
299
306
|
const res = await controlFetch('/__dsh_a2a/canvas', {
|
|
300
307
|
method: 'POST',
|
|
301
308
|
headers: { 'content-type': 'application/json' },
|
|
302
|
-
body: JSON.stringify({ action, name, id }),
|
|
309
|
+
body: JSON.stringify({ action, name, id, ...(home === undefined ? {} : { home }) }),
|
|
303
310
|
})
|
|
304
311
|
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
|
|
305
312
|
if (res.ok && body.ok !== false) return true
|
|
@@ -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
|
+
}
|