super-dsh 0.1.5 → 0.1.9
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/client/index.js +1010 -345
- package/cordis.patch.yml +22 -5
- package/package.json +8 -3
- package/superd-hub/dist/a2a-delivery.d.ts +189 -0
- package/superd-hub/dist/a2a-delivery.js +240 -0
- package/superd-hub/dist/a2a-host.d.ts +76 -0
- package/superd-hub/dist/a2a-host.js +145 -0
- package/superd-hub/dist/a2a-ingest.d.ts +31 -0
- package/superd-hub/dist/a2a-ingest.js +636 -0
- package/superd-hub/dist/a2a-log.d.ts +17 -0
- package/superd-hub/dist/a2a-log.js +42 -0
- package/superd-hub/dist/a2a-row.d.ts +65 -0
- package/superd-hub/dist/a2a-row.js +162 -0
- package/superd-hub/dist/a2a-service.d.ts +357 -0
- package/superd-hub/dist/a2a-service.js +980 -0
- package/superd-hub/dist/a2a-wire.d.ts +220 -0
- package/superd-hub/dist/a2a-wire.js +305 -0
- package/superd-hub/dist/agent-ids.d.ts +10 -0
- package/superd-hub/dist/agent-ids.js +68 -0
- package/superd-hub/dist/agent-roster.d.ts +32 -17
- package/superd-hub/dist/agent-roster.js +54 -15
- package/superd-hub/dist/agent-status.d.ts +119 -0
- package/superd-hub/dist/agent-status.js +386 -0
- package/superd-hub/dist/client/mesh-api.d.ts +134 -0
- package/superd-hub/dist/client/mesh-api.js +219 -0
- package/superd-hub/dist/client/mesh-groups.d.ts +63 -0
- package/superd-hub/dist/client/mesh-groups.js +66 -0
- package/superd-hub/dist/client/roster.d.ts +48 -0
- package/superd-hub/dist/client/roster.js +96 -0
- package/superd-hub/dist/exec-pty-channel.d.ts +58 -0
- package/superd-hub/dist/exec-pty-channel.js +162 -0
- package/superd-hub/dist/exec-pty-framing.d.ts +32 -0
- package/superd-hub/dist/exec-pty-framing.js +81 -0
- package/superd-hub/dist/exec-pty-rpc.d.ts +21 -0
- package/superd-hub/dist/exec-pty-rpc.js +128 -0
- package/superd-hub/dist/exec-pty-session.d.ts +76 -0
- package/superd-hub/dist/exec-pty-session.js +234 -0
- package/superd-hub/dist/exec.d.ts +56 -0
- package/superd-hub/dist/exec.js +225 -0
- package/superd-hub/dist/fusion-transport.d.ts +23 -21
- package/superd-hub/dist/fusion-transport.js +12 -40
- package/superd-hub/dist/gateway.d.ts +39 -2
- package/superd-hub/dist/gateway.js +62 -3
- package/superd-hub/dist/hub-tool.d.ts +174 -0
- package/superd-hub/dist/hub-tool.js +464 -0
- package/superd-hub/dist/index.d.ts +18 -4
- package/superd-hub/dist/index.js +19 -3
- package/superd-hub/dist/machines.d.ts +45 -0
- package/superd-hub/dist/machines.js +125 -0
- package/superd-hub/dist/mesh-federate.d.ts +138 -0
- package/superd-hub/dist/mesh-federate.js +223 -0
- package/superd-hub/dist/mesh-gateway.d.ts +67 -0
- package/superd-hub/dist/mesh-gateway.js +408 -0
- package/superd-hub/dist/mesh-link.d.ts +274 -0
- package/superd-hub/dist/mesh-link.js +441 -0
- package/superd-hub/dist/mesh-rpc.d.ts +123 -0
- package/superd-hub/dist/mesh-rpc.js +250 -0
- package/superd-hub/dist/mux-client.d.ts +71 -0
- package/superd-hub/dist/mux-client.js +281 -0
- package/superd-hub/dist/pack-version.d.ts +2 -0
- package/superd-hub/dist/pack-version.js +30 -0
- package/superd-hub/dist/roster.d.ts +10 -0
- package/superd-hub/dist/roster.js +9 -1
- package/superd-hub/dist/rpc.d.ts +18 -2
- package/superd-hub/dist/rpc.js +34 -4
- package/superd-hub/dist/spawn-world.js +3 -1
- package/superd-hub/dist/switch-core.d.ts +98 -21
- package/superd-hub/dist/switch-core.js +123 -28
- package/superd-hub/dist/system-head.d.ts +22 -12
- package/superd-hub/dist/system-head.js +100 -84
- package/superd-hub/dist/toast-core.d.ts +43 -0
- package/superd-hub/dist/toast-core.js +63 -0
- package/superd-hub/dist/wire-endpoints.d.ts +264 -0
- package/superd-hub/dist/wire-endpoints.js +180 -0
- package/superd-hub/dist/world-join.js +8 -3
- package/superd-hub/dist/world-mount.d.ts +27 -0
- package/superd-hub/dist/world-mount.js +67 -0
- package/superd-hub/lib/client/index.js +1010 -345
- package/superd-hub/package.json +11 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine registry (mesh P1, spec §3.1/§3.3): the persisted identity of
|
|
3
|
+
* THIS machine (`self`, owner-minted m4 per D4) plus the remote machine
|
|
4
|
+
* rows created by pairing.
|
|
5
|
+
*
|
|
6
|
+
* Pure module — no cordis dependencies. All state lives in
|
|
7
|
+
* `<home>/superd/machines.json` (0600, atomic tmp+rename writes); every
|
|
8
|
+
* mutation is a read-modify-write of the whole file with no in-process
|
|
9
|
+
* cache, which is sufficient at P1 machine counts. A missing or
|
|
10
|
+
* unreadable file reads as the empty shape (`self.id === ''` = not yet
|
|
11
|
+
* minted); `ensureSelf` mints lazily on first need and never re-mints.
|
|
12
|
+
*/
|
|
13
|
+
import { randomInt } from 'node:crypto';
|
|
14
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
17
|
+
const MINT_MAX_ATTEMPTS = 32;
|
|
18
|
+
function machinesPath(home) {
|
|
19
|
+
return join(home, 'superd', 'machines.json');
|
|
20
|
+
}
|
|
21
|
+
function emptyMachines() {
|
|
22
|
+
return { self: { id: '', label: '' }, remotes: [] };
|
|
23
|
+
}
|
|
24
|
+
function isMachineRow(v) {
|
|
25
|
+
if (typeof v !== 'object' || v === null)
|
|
26
|
+
return false;
|
|
27
|
+
const r = v;
|
|
28
|
+
return (typeof r.id === 'string' && typeof r.name === 'string' && typeof r.label === 'string' &&
|
|
29
|
+
typeof r.origin === 'string' && typeof r.enabled === 'boolean' && typeof r.addedAt === 'number' &&
|
|
30
|
+
(r.token === undefined || typeof r.token === 'string') &&
|
|
31
|
+
(r.cookie === undefined || typeof r.cookie === 'string') &&
|
|
32
|
+
(r.execEnabled === undefined || r.execEnabled === true || r.execEnabled === false));
|
|
33
|
+
}
|
|
34
|
+
/** Mint a random short id over [a-z0-9], re-minting on collision with `taken`; loud after 32 attempts. */
|
|
35
|
+
export function mintShortId(len, taken) {
|
|
36
|
+
const used = new Set(taken);
|
|
37
|
+
for (let attempt = 0; attempt < MINT_MAX_ATTEMPTS; attempt++) {
|
|
38
|
+
let id = '';
|
|
39
|
+
for (let i = 0; i < len; i++)
|
|
40
|
+
id += ID_ALPHABET[randomInt(ID_ALPHABET.length)];
|
|
41
|
+
if (!used.has(id))
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`machines: short-id space exhausted after ${MINT_MAX_ATTEMPTS} attempts (len=${len})`);
|
|
45
|
+
}
|
|
46
|
+
/** Read the registry; a missing or unreadable/corrupt file degrades to the empty shape (never throws). */
|
|
47
|
+
export function loadMachines(home) {
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = readFileSync(machinesPath(home), 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return emptyMachines();
|
|
54
|
+
}
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return emptyMachines();
|
|
61
|
+
}
|
|
62
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
63
|
+
return emptyMachines();
|
|
64
|
+
const o = parsed;
|
|
65
|
+
const self = o.self;
|
|
66
|
+
if (typeof self !== 'object' || self === null)
|
|
67
|
+
return emptyMachines();
|
|
68
|
+
const s = self;
|
|
69
|
+
if (typeof s.id !== 'string' || typeof s.label !== 'string' || !Array.isArray(o.remotes)) {
|
|
70
|
+
return emptyMachines();
|
|
71
|
+
}
|
|
72
|
+
return { self: { id: s.id, label: s.label }, remotes: o.remotes.filter(isMachineRow) };
|
|
73
|
+
}
|
|
74
|
+
/** Persist the whole registry: recursive mkdir, tmp write, chmod 0600, atomic rename. */
|
|
75
|
+
export function saveMachines(home, file) {
|
|
76
|
+
if (typeof file.self?.id !== 'string' || typeof file.self?.label !== 'string' || !Array.isArray(file.remotes)) {
|
|
77
|
+
throw new TypeError('machines: save rejected — file must be {self: {id, label}, remotes: []}');
|
|
78
|
+
}
|
|
79
|
+
for (const r of file.remotes) {
|
|
80
|
+
if (typeof r?.id !== 'string' || r.id === '' || typeof r.name !== 'string' || r.name === '' ||
|
|
81
|
+
typeof r.origin !== 'string' || r.origin === '') {
|
|
82
|
+
throw new TypeError('machines: save rejected — row id/name/origin must be non-empty strings');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const dir = join(home, 'superd');
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
const target = machinesPath(home);
|
|
88
|
+
const tmp = `${target}.tmp`;
|
|
89
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
90
|
+
chmodSync(tmp, 0o600);
|
|
91
|
+
renameSync(tmp, target);
|
|
92
|
+
}
|
|
93
|
+
/** Lazily mint + persist the self identity; an already-minted self is returned unchanged. */
|
|
94
|
+
export function ensureSelf(home, fallbackLabel) {
|
|
95
|
+
const file = loadMachines(home);
|
|
96
|
+
if (file.self.id !== '')
|
|
97
|
+
return file.self;
|
|
98
|
+
const taken = new Set([...file.remotes.map((r) => r.id)]);
|
|
99
|
+
const self = { id: mintShortId(4, taken), label: fallbackLabel };
|
|
100
|
+
saveMachines(home, { self, remotes: file.remotes });
|
|
101
|
+
return self;
|
|
102
|
+
}
|
|
103
|
+
/** Snapshot copy of the remote rows — callers can hold it without aliasing the store. */
|
|
104
|
+
export function listRemotes(home) {
|
|
105
|
+
return loadMachines(home).remotes.map((r) => ({ ...r }));
|
|
106
|
+
}
|
|
107
|
+
/** Insert or overwrite (same id) one remote row; full read-modify-write persistence. */
|
|
108
|
+
export function upsertRemote(home, row) {
|
|
109
|
+
const file = loadMachines(home);
|
|
110
|
+
const idx = file.remotes.findIndex((r) => r.id === row.id);
|
|
111
|
+
if (idx === -1)
|
|
112
|
+
file.remotes.push({ ...row });
|
|
113
|
+
else
|
|
114
|
+
file.remotes[idx] = { ...row };
|
|
115
|
+
saveMachines(home, file);
|
|
116
|
+
}
|
|
117
|
+
/** Drop one remote row; returns false when the id was not present. */
|
|
118
|
+
export function removeRemote(home, id) {
|
|
119
|
+
const file = loadMachines(home);
|
|
120
|
+
const remotes = file.remotes.filter((r) => r.id !== id);
|
|
121
|
+
if (remotes.length === file.remotes.length)
|
|
122
|
+
return false;
|
|
123
|
+
saveMachines(home, { self: file.self, remotes });
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federation registrar (machine-mesh P2 Task 3): adopts a paired machine's
|
|
3
|
+
* PROBED roster into the LOCAL registries, so a remote agent is addressable
|
|
4
|
+
* and visible exactly like a local world:
|
|
5
|
+
*
|
|
6
|
+
* targets.ts `registerForeignTarget({key: a6, gateway})` — the a6-keyed
|
|
7
|
+
* wire gateway (Task 2's `createRemoteGateway`), so the `/hub`
|
|
8
|
+
* family demuxes `?agent=<a6>` to the peer's `/hub` family
|
|
9
|
+
* carrying the remote RUNTIME key (`?agent=codex` at the peer).
|
|
10
|
+
* The peer's NATIVE agent row federates the same way — its
|
|
11
|
+
* row carries a minted a6 plus `key:'native'`, and its gateway
|
|
12
|
+
* is built with the NATIVE face (Task 2's `face` option): the
|
|
13
|
+
* peer's ctx0 `/api` surface, no demux key — exactly how a
|
|
14
|
+
* browser ON the peer consumes its native DSH (the `/hub`
|
|
15
|
+
* family would 404 `?agent=native` as foreign-only).
|
|
16
|
+
* roster.ts one row per agent: `{key: a6, label: '<Label> · <machine
|
|
17
|
+
* name>', machine: m4, ready, failed}` — `machine` set marks
|
|
18
|
+
* the row federated; `agentLinks` projects it into the UI rows.
|
|
19
|
+
*
|
|
20
|
+
* The a6 IS the local registration key (spec §3.2): runtime keys are NOT
|
|
21
|
+
* reused across machines (a remote `codex` would collide with — and silently
|
|
22
|
+
* replace — the local codex world), while a6s are owner-minted 6-char ids
|
|
23
|
+
* adopted verbatim. EVERY row federates, the peer's native included — the
|
|
24
|
+
* face selector is `agent.key === 'native'`, never an a6 test (only
|
|
25
|
+
* `'native'` is pre-screened as a hard reserve). Cross-machine a6 collision
|
|
26
|
+
* (two peers minting the same id, p ≈ 36⁻¹² per pair) and a6-vs-local-key
|
|
27
|
+
* collision (p ≈ 36⁻⁶ per adoption) are accepted at P2 machine counts — the
|
|
28
|
+
* registry replaces last-write-wins, same ruling as spec §3.1's m4 collision
|
|
29
|
+
* roughness.
|
|
30
|
+
*
|
|
31
|
+
* ## Import direction (frozen): mesh-federate → {mesh-link, mesh-gateway,
|
|
32
|
+
* ## roster, targets} — NEVER the reverse. mesh-link emits machine lifecycle
|
|
33
|
+
* ## events (`onMeshFederationEvent`: federated / degraded / defederated);
|
|
34
|
+
* ## this module subscribes at wiring time ({@link startMeshFederation},
|
|
35
|
+
* ## called once from gateway.ts apply). mesh-rpc's DELETE path announces
|
|
36
|
+
* ## removals through mesh-link's {@link emitMachineRemoved} — no route-module
|
|
37
|
+
* ## federation logic anywhere.
|
|
38
|
+
*
|
|
39
|
+
* ## Gateway memo — one `RemoteGateway` per (m4, a6), built by the wiring's
|
|
40
|
+
* ## factory, keyed by a6. Reused across probe events (identity-stable: a
|
|
41
|
+
* ## reprobe does not rebuild a live gateway); rebuilt when the peer facts
|
|
42
|
+
* ## change (origin moved, remote runtime key re-minted); disposed and
|
|
43
|
+
* ## forgotten on defederate. The cookie is NOT memoized — the gateway reads
|
|
44
|
+
* ## it per call through `cookieOf(m4, home)` (jar-fronted), so a re-minted
|
|
45
|
+
* ## cookie is picked up by the NEXT call with zero rebuilds.
|
|
46
|
+
*
|
|
47
|
+
* ## Degrade semantics (spec §6.3): a failed probe KEEPS the machine's rows
|
|
48
|
+
* ## and flips `ready` false — visible but not servable. Defederate (machine
|
|
49
|
+
* ## removal) and stale-convergence (an agent vanished from the peer's
|
|
50
|
+
* ## roster) are the only row-removing paths.
|
|
51
|
+
*/
|
|
52
|
+
import { type MuxClientSocket } from './mesh-gateway.js';
|
|
53
|
+
import { type GatewayFace } from './targets.js';
|
|
54
|
+
/** One remote agent as adopted from a probe — mesh-link's `ProbeAgent` shape
|
|
55
|
+
* (kept structural so callers and tests can pass plain objects). */
|
|
56
|
+
export interface FederatedAgent {
|
|
57
|
+
/** The remote-owner-minted a6 — the LOCAL registration key. */
|
|
58
|
+
id: string;
|
|
59
|
+
/** The remote RUNTIME key (`codex`, `omp`, …) — the peer's `?agent=` demux
|
|
60
|
+
* key on the wire; `'native'` selects the NATIVE face (peer ctx0 `/api`, no
|
|
61
|
+
* demux key) in the wiring's gateway factory. An EMPTY key cannot address
|
|
62
|
+
* anything at the peer; {@link gatewayFor} falls back to the a6 there
|
|
63
|
+
* (T3-M1) rather than shipping `?agent=` on the wire. */
|
|
64
|
+
key: string;
|
|
65
|
+
label: string;
|
|
66
|
+
ready: boolean;
|
|
67
|
+
failed: boolean;
|
|
68
|
+
}
|
|
69
|
+
/** The machine half of a federation fact (m4 + display name for label suffixes). */
|
|
70
|
+
export interface FederatedMachine {
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
}
|
|
74
|
+
/** Wiring deps: the hub home (cookie row fallback) + test seams for the wire. */
|
|
75
|
+
export interface FederationWireDeps {
|
|
76
|
+
home: string;
|
|
77
|
+
fetchImpl?: typeof fetch;
|
|
78
|
+
wsFactory?: (url: string, headers: Record<string, string>) => MuxClientSocket;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Register one machine's probed agents — IDEMPOTENT replace per a6: a row
|
|
82
|
+
* whose key/label/machine/ready/failed all match the live registry is left
|
|
83
|
+
* untouched (zero roster churn on repeated probes); a changed row is
|
|
84
|
+
* unregistered then re-registered (`registerAgent` throws on duplicate keys —
|
|
85
|
+
* federate always unregisters first, so repeated probes never throw).
|
|
86
|
+
* Agents with an empty id (unkeyable) are skipped; EVERY keyed row
|
|
87
|
+
* federates — the peer's native row included (its `key:'native'` selects the
|
|
88
|
+
* native gateway face; see {@link gatewayFor}).
|
|
89
|
+
* @param machine - the peer's identity (m4 + name; the name lands in every label suffix).
|
|
90
|
+
* @param agents - the probe's agent snapshot (a6 `id`, remote `key`, label, ready, failed).
|
|
91
|
+
* @param gatewayOf - gateway factory receiving the FULL agent row (the wiring
|
|
92
|
+
* derives the face from `agent.key`; tests inject fakes) — called once per
|
|
93
|
+
* (re)registration and once per converged-row target re-arm.
|
|
94
|
+
*/
|
|
95
|
+
export declare function federateAgents(machine: FederatedMachine, agents: FederatedAgent[], gatewayOf: (agent: FederatedAgent) => GatewayFace): void;
|
|
96
|
+
/**
|
|
97
|
+
* Drop EVERY federated row of one machine (mesh-rpc DELETE / unpair): roster
|
|
98
|
+
* rows, target registrations, and the memoized wire gateways (disposed —
|
|
99
|
+
* in-flight streams fail `gateway/disposed` per Task 2's contract).
|
|
100
|
+
*/
|
|
101
|
+
export declare function defederateMachine(m4: string): void;
|
|
102
|
+
/**
|
|
103
|
+
* Roster convergence: unregister the machine's rows whose a6s are ABSENT from
|
|
104
|
+
* the peer's latest probe (the agent was removed/renamed remotely) — live
|
|
105
|
+
* agents are left to {@link federateAgents}.
|
|
106
|
+
* @param m4 - the machine id.
|
|
107
|
+
* @param liveA6s - the a6 set of the latest successful probe.
|
|
108
|
+
*/
|
|
109
|
+
export declare function defederateStaleMachine(m4: string, liveA6s: ReadonlySet<string>): void;
|
|
110
|
+
/**
|
|
111
|
+
* Degrade one machine's rows after a failed probe (unreachable peer or
|
|
112
|
+
* auth-expired cookie): rows are KEPT, `ready` flipped false, `failed`
|
|
113
|
+
* untouched — visible but not servable (spec §6.3; the orchestra downgrade).
|
|
114
|
+
* Rows already degraded are left alone (no roster churn); local rows are
|
|
115
|
+
* never touched.
|
|
116
|
+
*/
|
|
117
|
+
export declare function degradeMachine(m4: string): void;
|
|
118
|
+
/**
|
|
119
|
+
* The federated gateway of one a6 — the `/hub` demux's federation-table step
|
|
120
|
+
* (exact a6 match; the same pair `federateAgents` registers into the shared
|
|
121
|
+
* target registry, so the two sources never disagree). Undefined for local
|
|
122
|
+
* labels and unknown/defederated a6s.
|
|
123
|
+
*/
|
|
124
|
+
export declare function federatedGatewayOf(a6: string): GatewayFace | undefined;
|
|
125
|
+
/**
|
|
126
|
+
* Wire the registrar to mesh-link's machine lifecycle events (called ONCE at
|
|
127
|
+
* plugin activation — gateway.ts apply):
|
|
128
|
+
*
|
|
129
|
+
* - `federated` → {@link federateAgents} (converge onto the probe snapshot)
|
|
130
|
+
* + {@link defederateStaleMachine} (drop vanished agents);
|
|
131
|
+
* - `degraded` → {@link degradeMachine} (rows kept, ready=false);
|
|
132
|
+
* - `defederated` → {@link defederateMachine} (mesh-rpc DELETE).
|
|
133
|
+
*
|
|
134
|
+
* Re-probing a machine after a hub restart rebuilds its federation from the
|
|
135
|
+
* probe alone — the events are the single rebuild path.
|
|
136
|
+
* @returns the disposer (unsubscribes; registered rows are left as-is).
|
|
137
|
+
*/
|
|
138
|
+
export declare function startMeshFederation(deps: FederationWireDeps): () => void;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federation registrar (machine-mesh P2 Task 3): adopts a paired machine's
|
|
3
|
+
* PROBED roster into the LOCAL registries, so a remote agent is addressable
|
|
4
|
+
* and visible exactly like a local world:
|
|
5
|
+
*
|
|
6
|
+
* targets.ts `registerForeignTarget({key: a6, gateway})` — the a6-keyed
|
|
7
|
+
* wire gateway (Task 2's `createRemoteGateway`), so the `/hub`
|
|
8
|
+
* family demuxes `?agent=<a6>` to the peer's `/hub` family
|
|
9
|
+
* carrying the remote RUNTIME key (`?agent=codex` at the peer).
|
|
10
|
+
* The peer's NATIVE agent row federates the same way — its
|
|
11
|
+
* row carries a minted a6 plus `key:'native'`, and its gateway
|
|
12
|
+
* is built with the NATIVE face (Task 2's `face` option): the
|
|
13
|
+
* peer's ctx0 `/api` surface, no demux key — exactly how a
|
|
14
|
+
* browser ON the peer consumes its native DSH (the `/hub`
|
|
15
|
+
* family would 404 `?agent=native` as foreign-only).
|
|
16
|
+
* roster.ts one row per agent: `{key: a6, label: '<Label> · <machine
|
|
17
|
+
* name>', machine: m4, ready, failed}` — `machine` set marks
|
|
18
|
+
* the row federated; `agentLinks` projects it into the UI rows.
|
|
19
|
+
*
|
|
20
|
+
* The a6 IS the local registration key (spec §3.2): runtime keys are NOT
|
|
21
|
+
* reused across machines (a remote `codex` would collide with — and silently
|
|
22
|
+
* replace — the local codex world), while a6s are owner-minted 6-char ids
|
|
23
|
+
* adopted verbatim. EVERY row federates, the peer's native included — the
|
|
24
|
+
* face selector is `agent.key === 'native'`, never an a6 test (only
|
|
25
|
+
* `'native'` is pre-screened as a hard reserve). Cross-machine a6 collision
|
|
26
|
+
* (two peers minting the same id, p ≈ 36⁻¹² per pair) and a6-vs-local-key
|
|
27
|
+
* collision (p ≈ 36⁻⁶ per adoption) are accepted at P2 machine counts — the
|
|
28
|
+
* registry replaces last-write-wins, same ruling as spec §3.1's m4 collision
|
|
29
|
+
* roughness.
|
|
30
|
+
*
|
|
31
|
+
* ## Import direction (frozen): mesh-federate → {mesh-link, mesh-gateway,
|
|
32
|
+
* ## roster, targets} — NEVER the reverse. mesh-link emits machine lifecycle
|
|
33
|
+
* ## events (`onMeshFederationEvent`: federated / degraded / defederated);
|
|
34
|
+
* ## this module subscribes at wiring time ({@link startMeshFederation},
|
|
35
|
+
* ## called once from gateway.ts apply). mesh-rpc's DELETE path announces
|
|
36
|
+
* ## removals through mesh-link's {@link emitMachineRemoved} — no route-module
|
|
37
|
+
* ## federation logic anywhere.
|
|
38
|
+
*
|
|
39
|
+
* ## Gateway memo — one `RemoteGateway` per (m4, a6), built by the wiring's
|
|
40
|
+
* ## factory, keyed by a6. Reused across probe events (identity-stable: a
|
|
41
|
+
* ## reprobe does not rebuild a live gateway); rebuilt when the peer facts
|
|
42
|
+
* ## change (origin moved, remote runtime key re-minted); disposed and
|
|
43
|
+
* ## forgotten on defederate. The cookie is NOT memoized — the gateway reads
|
|
44
|
+
* ## it per call through `cookieOf(m4, home)` (jar-fronted), so a re-minted
|
|
45
|
+
* ## cookie is picked up by the NEXT call with zero rebuilds.
|
|
46
|
+
*
|
|
47
|
+
* ## Degrade semantics (spec §6.3): a failed probe KEEPS the machine's rows
|
|
48
|
+
* ## and flips `ready` false — visible but not servable. Defederate (machine
|
|
49
|
+
* ## removal) and stale-convergence (an agent vanished from the peer's
|
|
50
|
+
* ## roster) are the only row-removing paths.
|
|
51
|
+
*/
|
|
52
|
+
import { createRemoteGateway } from './mesh-gateway.js';
|
|
53
|
+
import { cookieOf, onMeshFederationEvent } from './mesh-link.js';
|
|
54
|
+
import { registerAgent, unregisterAgent, listAgents, setReady } from './roster.js';
|
|
55
|
+
import { registerForeignTarget, unregisterForeignTarget, getTarget } from './targets.js';
|
|
56
|
+
const gatewayMemo = new Map();
|
|
57
|
+
/** Roster rows of one machine (its a6s), from the live registry. */
|
|
58
|
+
function rowsOf(m4) {
|
|
59
|
+
return listAgents().filter((e) => (e.machine ?? 'local') === m4);
|
|
60
|
+
}
|
|
61
|
+
/** Unregister one federated a6 from both registries + drop its memo gateway. */
|
|
62
|
+
function dropA6(a6) {
|
|
63
|
+
if (listAgents().some((e) => e.key === a6))
|
|
64
|
+
unregisterAgent(a6); // guard: unregister no-ops still emit roster churn
|
|
65
|
+
unregisterForeignTarget(a6);
|
|
66
|
+
const memo = gatewayMemo.get(a6);
|
|
67
|
+
if (memo !== undefined) {
|
|
68
|
+
gatewayMemo.delete(a6);
|
|
69
|
+
memo.gateway.dispose();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Register one machine's probed agents — IDEMPOTENT replace per a6: a row
|
|
74
|
+
* whose key/label/machine/ready/failed all match the live registry is left
|
|
75
|
+
* untouched (zero roster churn on repeated probes); a changed row is
|
|
76
|
+
* unregistered then re-registered (`registerAgent` throws on duplicate keys —
|
|
77
|
+
* federate always unregisters first, so repeated probes never throw).
|
|
78
|
+
* Agents with an empty id (unkeyable) are skipped; EVERY keyed row
|
|
79
|
+
* federates — the peer's native row included (its `key:'native'` selects the
|
|
80
|
+
* native gateway face; see {@link gatewayFor}).
|
|
81
|
+
* @param machine - the peer's identity (m4 + name; the name lands in every label suffix).
|
|
82
|
+
* @param agents - the probe's agent snapshot (a6 `id`, remote `key`, label, ready, failed).
|
|
83
|
+
* @param gatewayOf - gateway factory receiving the FULL agent row (the wiring
|
|
84
|
+
* derives the face from `agent.key`; tests inject fakes) — called once per
|
|
85
|
+
* (re)registration and once per converged-row target re-arm.
|
|
86
|
+
*/
|
|
87
|
+
export function federateAgents(machine, agents, gatewayOf) {
|
|
88
|
+
for (const agent of agents) {
|
|
89
|
+
if (agent.id === '')
|
|
90
|
+
continue;
|
|
91
|
+
const desired = {
|
|
92
|
+
key: agent.id,
|
|
93
|
+
label: `${agent.label} · ${machine.name}`,
|
|
94
|
+
machine: machine.id,
|
|
95
|
+
ready: agent.ready === true,
|
|
96
|
+
failed: agent.failed === true,
|
|
97
|
+
};
|
|
98
|
+
const existing = listAgents().find((e) => e.key === agent.id);
|
|
99
|
+
if (existing !== undefined
|
|
100
|
+
&& existing.label === desired.label
|
|
101
|
+
&& existing.ready === desired.ready
|
|
102
|
+
&& (existing.failed ?? false) === desired.failed
|
|
103
|
+
&& (existing.machine ?? 'local') === machine.id) {
|
|
104
|
+
// Converged — but never trust a half-state: the roster row may survive
|
|
105
|
+
// a target that something else dropped. Re-arm the registration only.
|
|
106
|
+
if (getTarget(agent.id) === undefined)
|
|
107
|
+
registerForeignTarget({ key: agent.id, gateway: gatewayOf(agent) });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (existing !== undefined)
|
|
111
|
+
unregisterAgent(agent.id); // defeats registerAgent's duplicate-key throw
|
|
112
|
+
unregisterForeignTarget(agent.id); // explicit replace, not map-overwrite
|
|
113
|
+
registerForeignTarget({ key: agent.id, gateway: gatewayOf(agent) });
|
|
114
|
+
registerAgent(desired);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Drop EVERY federated row of one machine (mesh-rpc DELETE / unpair): roster
|
|
119
|
+
* rows, target registrations, and the memoized wire gateways (disposed —
|
|
120
|
+
* in-flight streams fail `gateway/disposed` per Task 2's contract).
|
|
121
|
+
*/
|
|
122
|
+
export function defederateMachine(m4) {
|
|
123
|
+
for (const row of rowsOf(m4))
|
|
124
|
+
dropA6(row.key);
|
|
125
|
+
// Belt: memo entries whose roster rows vanished independently still die with the machine.
|
|
126
|
+
for (const [a6, memo] of [...gatewayMemo]) {
|
|
127
|
+
if (memo.machineId === m4)
|
|
128
|
+
dropA6(a6);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Roster convergence: unregister the machine's rows whose a6s are ABSENT from
|
|
133
|
+
* the peer's latest probe (the agent was removed/renamed remotely) — live
|
|
134
|
+
* agents are left to {@link federateAgents}.
|
|
135
|
+
* @param m4 - the machine id.
|
|
136
|
+
* @param liveA6s - the a6 set of the latest successful probe.
|
|
137
|
+
*/
|
|
138
|
+
export function defederateStaleMachine(m4, liveA6s) {
|
|
139
|
+
for (const row of rowsOf(m4)) {
|
|
140
|
+
if (!liveA6s.has(row.key))
|
|
141
|
+
dropA6(row.key);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Degrade one machine's rows after a failed probe (unreachable peer or
|
|
146
|
+
* auth-expired cookie): rows are KEPT, `ready` flipped false, `failed`
|
|
147
|
+
* untouched — visible but not servable (spec §6.3; the orchestra downgrade).
|
|
148
|
+
* Rows already degraded are left alone (no roster churn); local rows are
|
|
149
|
+
* never touched.
|
|
150
|
+
*/
|
|
151
|
+
export function degradeMachine(m4) {
|
|
152
|
+
for (const row of rowsOf(m4)) {
|
|
153
|
+
if (row.ready !== false)
|
|
154
|
+
setReady(row.key, false);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The federated gateway of one a6 — the `/hub` demux's federation-table step
|
|
159
|
+
* (exact a6 match; the same pair `federateAgents` registers into the shared
|
|
160
|
+
* target registry, so the two sources never disagree). Undefined for local
|
|
161
|
+
* labels and unknown/defederated a6s.
|
|
162
|
+
*/
|
|
163
|
+
export function federatedGatewayOf(a6) {
|
|
164
|
+
return gatewayMemo.get(a6)?.gateway;
|
|
165
|
+
}
|
|
166
|
+
/** The wiring's gateway factory: memoized per a6, rebuilt on peer-fact
|
|
167
|
+
* change (origin, remote runtime key, or face flip). The FACE is selected
|
|
168
|
+
* from the agent ROW — `key === 'native'` → the peer's ctx0 `/api` surface,
|
|
169
|
+
* anything else → the `/hub` family with the runtime-key demux. */
|
|
170
|
+
function gatewayFor(event, agent, deps) {
|
|
171
|
+
const a6 = agent.id;
|
|
172
|
+
const face = agent.key === 'native' ? 'native' : 'foreign';
|
|
173
|
+
// T3-M1: an empty remote key must not ride the wire as `?agent=` (the
|
|
174
|
+
// nullish class miss — '' is not nullish); fall back to the a6 so the
|
|
175
|
+
// registration stays addressable and the wrong key fails LOUDLY at the
|
|
176
|
+
// peer (its demux 404s the unknown key) instead of silently here.
|
|
177
|
+
const agentKey = agent.key !== '' ? agent.key : agent.id;
|
|
178
|
+
const memo = gatewayMemo.get(a6);
|
|
179
|
+
if (memo !== undefined && memo.machineId === event.machineId && memo.origin === event.origin && memo.agentKey === agentKey && memo.face === face) {
|
|
180
|
+
return memo.gateway;
|
|
181
|
+
}
|
|
182
|
+
if (memo !== undefined) {
|
|
183
|
+
gatewayMemo.delete(a6);
|
|
184
|
+
memo.gateway.dispose();
|
|
185
|
+
}
|
|
186
|
+
const gateway = createRemoteGateway({
|
|
187
|
+
origin: event.origin,
|
|
188
|
+
agentKey,
|
|
189
|
+
face,
|
|
190
|
+
cookie: () => cookieOf(event.machineId, deps.home),
|
|
191
|
+
fetchImpl: deps.fetchImpl,
|
|
192
|
+
wsFactory: deps.wsFactory,
|
|
193
|
+
});
|
|
194
|
+
gatewayMemo.set(a6, { gateway, machineId: event.machineId, origin: event.origin, agentKey, face });
|
|
195
|
+
return gateway;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Wire the registrar to mesh-link's machine lifecycle events (called ONCE at
|
|
199
|
+
* plugin activation — gateway.ts apply):
|
|
200
|
+
*
|
|
201
|
+
* - `federated` → {@link federateAgents} (converge onto the probe snapshot)
|
|
202
|
+
* + {@link defederateStaleMachine} (drop vanished agents);
|
|
203
|
+
* - `degraded` → {@link degradeMachine} (rows kept, ready=false);
|
|
204
|
+
* - `defederated` → {@link defederateMachine} (mesh-rpc DELETE).
|
|
205
|
+
*
|
|
206
|
+
* Re-probing a machine after a hub restart rebuilds its federation from the
|
|
207
|
+
* probe alone — the events are the single rebuild path.
|
|
208
|
+
* @returns the disposer (unsubscribes; registered rows are left as-is).
|
|
209
|
+
*/
|
|
210
|
+
export function startMeshFederation(deps) {
|
|
211
|
+
return onMeshFederationEvent((event) => {
|
|
212
|
+
if (event.type === 'federated') {
|
|
213
|
+
federateAgents({ id: event.machineId, name: event.machineName }, event.agents, (agent) => gatewayFor(event, agent, deps));
|
|
214
|
+
defederateStaleMachine(event.machineId, new Set(event.agents.map((a) => a.id)));
|
|
215
|
+
}
|
|
216
|
+
else if (event.type === 'degraded') {
|
|
217
|
+
degradeMachine(event.machineId);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
defederateMachine(event.machineId);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { MuxClientSocket } from './mux-client.js';
|
|
2
|
+
import type { GatewayFace } from './targets.js';
|
|
3
|
+
export type { MuxClientSocket } from './mux-client.js';
|
|
4
|
+
/**
|
|
5
|
+
* The default ws seam: connect one `ws` WebSocket to `url` with `headers`
|
|
6
|
+
* riding the upgrade request (the pairing Cookie), presented as T1's
|
|
7
|
+
* {@link MuxClientSocket}.
|
|
8
|
+
*
|
|
9
|
+
* The adapter is crash-proof by construction (fix round 1):
|
|
10
|
+
* - an `'error'` listener is ALWAYS registered: an unreachable peer
|
|
11
|
+
* (ECONNREFUSED) or a rejected upgrade (non-101, e.g. a dead cookie's
|
|
12
|
+
* HTTP 401) emits `'error'` with no listener of its own — an uncaught
|
|
13
|
+
* exception that kills the hub process. The listener is a no-op: ws
|
|
14
|
+
* guarantees `'close'` follows `'error'`, and the close path already
|
|
15
|
+
* classifies (in-flight streams fail `gateway/socket-closed`) and retires
|
|
16
|
+
* the connection for redial. WS-lane auth failure therefore classifies
|
|
17
|
+
* as a socket cut post-fix — acceptable: the next POST-lane call is the
|
|
18
|
+
* one that reports `gateway/auth-expired`.
|
|
19
|
+
* - `ws` THROWS on sends made while CONNECTING, and its post-close send
|
|
20
|
+
* bookkeeping can itself throw in teardown windows, so every send is
|
|
21
|
+
* guarded: pre-open sends queue (flushed in order on 'open'), post-close
|
|
22
|
+
* sends are swallowed — the stream is dead and the close path owns the
|
|
23
|
+
* teardown. WS-level pings stay at the WS layer: `on('message')` fires
|
|
24
|
+
* for text data frames only, so protocol-level ping/pong NEVER surfaces
|
|
25
|
+
* as text.
|
|
26
|
+
*/
|
|
27
|
+
export declare function defaultWsFactory(url: string, headers: Record<string, string>): MuxClientSocket;
|
|
28
|
+
/** Options for {@link createRemoteGateway}. */
|
|
29
|
+
export interface RemoteGatewayOptions {
|
|
30
|
+
/** The peer hub origin (`http(s)://host[:port]`; the mux lane maps it to ws(s)). */
|
|
31
|
+
origin: string;
|
|
32
|
+
/** The remote agent's demux key (`?agent=` on both lanes; ignored by the native face). */
|
|
33
|
+
agentKey: string;
|
|
34
|
+
/** The machine's pairing cookie, read per call (the jar fronting `MachineRow.cookie`). */
|
|
35
|
+
cookie: () => string;
|
|
36
|
+
/** Fetch override (tests); defaults to `globalThis.fetch`. */
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
/** Socket factory override (tests); defaults to {@link defaultWsFactory}. */
|
|
39
|
+
wsFactory?: (url: string, headers: Record<string, string>) => MuxClientSocket;
|
|
40
|
+
/** Which peer surface to address (see the header's face table): 'foreign' =
|
|
41
|
+
* the `/hub` family with the `?agent=` demux (default); 'native' = ctx0's
|
|
42
|
+
* own `/api` surface, no demux key — how a browser ON the peer consumes its
|
|
43
|
+
* native DSH. */
|
|
44
|
+
face?: 'foreign' | 'native';
|
|
45
|
+
/** Idle ms with zero live streams before the ws closes (default 30 s; test hook for short real timers). */
|
|
46
|
+
idleCloseMs?: number;
|
|
47
|
+
}
|
|
48
|
+
/** A wire-form gateway: a {@link GatewayFace} plus the carrier call shape and a hard close. */
|
|
49
|
+
export interface RemoteGateway extends GatewayFace {
|
|
50
|
+
/**
|
|
51
|
+
* CarrierGatewayFace shape — the canonical order `worldMuxOf` calls with
|
|
52
|
+
* (`(endpoint, payload, uplink, peer, signal, control)`); the uplink is
|
|
53
|
+
* bridged into the wire upstream lane.
|
|
54
|
+
*/
|
|
55
|
+
openWireStream(endpoint: string, payload: unknown, uplink: AsyncIterable<unknown> | Iterable<unknown>, peer: undefined, signal: AbortSignal, control: AbortController): AsyncIterable<unknown>;
|
|
56
|
+
/** GatewayFace shape — no uplink lane; the stream's uplink half-closes immediately. */
|
|
57
|
+
openWireStream(endpoint: string, payload: unknown, signal: AbortSignal): AsyncIterable<unknown>;
|
|
58
|
+
/** Hard-close the shared connection now (machine unpair); in-flight streams fail `gateway/disposed`. */
|
|
59
|
+
dispose(): void;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build one remote agent's wire gateway toward a paired hub's `/hub` family.
|
|
63
|
+
* @param options - see {@link RemoteGatewayOptions}.
|
|
64
|
+
* @returns the {@link RemoteGateway} (a GatewayFace; register it with
|
|
65
|
+
* `registerForeignTarget` — callers cannot tell it from a local world gateway).
|
|
66
|
+
*/
|
|
67
|
+
export declare function createRemoteGateway(options: RemoteGatewayOptions): RemoteGateway;
|