@animalabs/connectome-host 0.3.1 → 0.3.5
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/.github/workflows/publish.yml +9 -4
- package/package.json +3 -3
- package/src/commands.ts +57 -3
- package/src/index.ts +9 -0
- package/src/modules/fleet-module.ts +4 -2
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/web-ui-module.ts +390 -16
- package/src/modules/web-ui-observers.ts +309 -0
- package/src/web/protocol.ts +52 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
- package/test/web-ui-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +301 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +15 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +68 -0
- package/web/src/observer-identity.ts +110 -0
- package/web/src/wire.ts +57 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observer identity for the webui — the M2 layer of connectome
|
|
3
|
+
* docs/observability.md.
|
|
4
|
+
*
|
|
5
|
+
* An observer is an Archipelago layer-0 principal: an ed25519 keypair,
|
|
6
|
+
* human device or agent or service. Authorization is a per-agent grant file
|
|
7
|
+
* (data/observers.json) that is hot-reloaded on mtime (the proven
|
|
8
|
+
* discord-filters pattern: atomic tmp+rename writes, parse errors keep the
|
|
9
|
+
* previous state) and editable by the agent itself through the observers
|
|
10
|
+
* module's tools — interiority access is a consent question, so the agent
|
|
11
|
+
* holds the pen.
|
|
12
|
+
*
|
|
13
|
+
* The handshake is challenge-less: the client signs a statement binding the
|
|
14
|
+
* Host it connected to + a fresh timestamp (replay/relay-proof, no extra
|
|
15
|
+
* round trip):
|
|
16
|
+
*
|
|
17
|
+
* sign( "connectome-observer|v1|<host>|<timestamp>" )
|
|
18
|
+
*
|
|
19
|
+
* The server verifies signature, freshness (±5 min), and that the key has a
|
|
20
|
+
* live grant — then the connection carries that grant's scope mask.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, renameSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
23
|
+
import { dirname } from 'node:path';
|
|
24
|
+
import { createPublicKey, verify as cryptoVerify, randomBytes } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
/** Scope = event-family mask. See docs/observability.md §4. */
|
|
27
|
+
export const OBSERVER_SCOPES = ['health', 'ops', 'messages', 'tools', 'thinking', 'debug'] as const;
|
|
28
|
+
export type ObserverScope = typeof OBSERVER_SCOPES[number];
|
|
29
|
+
|
|
30
|
+
export interface ObserverGrant {
|
|
31
|
+
/** `ed25519:<base64url of the raw 32-byte public key>` */
|
|
32
|
+
key: string;
|
|
33
|
+
/** Human-readable label. Testimony, not identity — never treat as verified naming. */
|
|
34
|
+
label: string;
|
|
35
|
+
scopes: ObserverScope[];
|
|
36
|
+
/** ISO expiry; null/absent = no expiry. */
|
|
37
|
+
expires?: string | null;
|
|
38
|
+
/** Protected grants cannot be revoked via the agent-facing tools
|
|
39
|
+
* (recipe-designated operator baseline). File edits can still remove them. */
|
|
40
|
+
protected?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ObserversFile {
|
|
44
|
+
observers: ObserverGrant[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The identity envelope a client presents in its observer-hello frame. */
|
|
48
|
+
export interface ObserverHelloIdentity {
|
|
49
|
+
scheme: 'ed25519';
|
|
50
|
+
/** `ed25519:<base64url raw pubkey>` — must match a grant. */
|
|
51
|
+
id: string;
|
|
52
|
+
/** base64url ed25519 signature over the bound statement. */
|
|
53
|
+
proof: string;
|
|
54
|
+
/** ISO timestamp the statement was signed at (freshness-checked). */
|
|
55
|
+
timestamp: string;
|
|
56
|
+
displayName?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const FRESHNESS_MS = 5 * 60_000;
|
|
60
|
+
|
|
61
|
+
export function observerStatement(host: string, timestamp: string): string {
|
|
62
|
+
return `connectome-observer|v1|${host}|${timestamp}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function b64urlToBuf(s: string): Buffer | null {
|
|
66
|
+
try {
|
|
67
|
+
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Raw 32-byte ed25519 public key → node KeyObject (SPKI DER wrap). */
|
|
74
|
+
export function ed25519PublicKey(raw: Buffer) {
|
|
75
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
76
|
+
return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// File ops — the discord-mcpl filters.ts pattern, verbatim mechanics.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export function loadObserversFile(path: string): ObserversFile | null {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as ObserversFile;
|
|
86
|
+
if (!parsed || !Array.isArray(parsed.observers)) return null;
|
|
87
|
+
for (const g of parsed.observers) {
|
|
88
|
+
if (typeof g.key !== 'string' || !g.key.startsWith('ed25519:')) return null;
|
|
89
|
+
if (!Array.isArray(g.scopes)) return null;
|
|
90
|
+
if (!g.scopes.every((s) => (OBSERVER_SCOPES as readonly string[]).includes(s))) return null;
|
|
91
|
+
}
|
|
92
|
+
return parsed;
|
|
93
|
+
} catch {
|
|
94
|
+
return null; // parse/validation error → caller keeps previous (fail-safe)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function saveObserversFile(path: string, file: ObserversFile): void {
|
|
99
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
100
|
+
const tmp = `${path}.tmp`;
|
|
101
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n');
|
|
102
|
+
renameSync(tmp, path); // atomic — mtime pollers never read a half-write
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function observersFileMtime(path: string): number | null {
|
|
106
|
+
try {
|
|
107
|
+
return statSync(path).mtimeMs;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Registry
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export interface VerifyResult {
|
|
118
|
+
grant: ObserverGrant;
|
|
119
|
+
scopes: Set<ObserverScope>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class ObserverRegistry {
|
|
123
|
+
private file: ObserversFile | null = null;
|
|
124
|
+
private lastMtime: number | null = null;
|
|
125
|
+
private poll: ReturnType<typeof setInterval> | null = null;
|
|
126
|
+
|
|
127
|
+
constructor(private readonly path: string) {}
|
|
128
|
+
|
|
129
|
+
start(): void {
|
|
130
|
+
this.lastMtime = observersFileMtime(this.path);
|
|
131
|
+
if (this.lastMtime !== null) {
|
|
132
|
+
this.file = loadObserversFile(this.path);
|
|
133
|
+
if (!this.file) console.error(`[webui-observers] ${this.path} unparseable — observers disabled until fixed`);
|
|
134
|
+
}
|
|
135
|
+
this.poll = setInterval(() => {
|
|
136
|
+
const m = observersFileMtime(this.path);
|
|
137
|
+
if (m === null || m === this.lastMtime) return; // missing/mid-rename or unchanged
|
|
138
|
+
this.lastMtime = m;
|
|
139
|
+
const next = loadObserversFile(this.path);
|
|
140
|
+
if (!next) {
|
|
141
|
+
console.error(`[webui-observers] ${this.path} unparseable — keeping previous grants`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.file = next;
|
|
145
|
+
console.error(`[webui-observers] reloaded ${next.observers.length} grant(s)`);
|
|
146
|
+
}, 3000);
|
|
147
|
+
this.poll.unref();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
stop(): void {
|
|
151
|
+
if (this.poll) clearInterval(this.poll);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** True when at least one live grant exists — gates the whole feature:
|
|
155
|
+
* no grants ⇒ webui behaves exactly as before this layer existed. */
|
|
156
|
+
active(): boolean {
|
|
157
|
+
return (this.file?.observers.length ?? 0) > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
grants(): ObserverGrant[] {
|
|
161
|
+
return this.file?.observers ?? [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Verify an observer-hello identity envelope against the live grants.
|
|
166
|
+
* Returns the grant + scope set, or null (with a stderr reason — auth
|
|
167
|
+
* failures on a headless box must be diagnosable).
|
|
168
|
+
*/
|
|
169
|
+
verifyHello(identity: ObserverHelloIdentity, hostHeader: string, now = Date.now()): VerifyResult | null {
|
|
170
|
+
const fail = (why: string): null => {
|
|
171
|
+
console.error(`[webui-observers] hello rejected (${identity?.id ?? 'no-id'}): ${why}`);
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
if (!identity || identity.scheme !== 'ed25519') return fail('unsupported scheme');
|
|
175
|
+
if (typeof identity.id !== 'string' || !identity.id.startsWith('ed25519:')) return fail('bad key id');
|
|
176
|
+
|
|
177
|
+
const ts = Date.parse(identity.timestamp ?? '');
|
|
178
|
+
if (!Number.isFinite(ts)) return fail('bad timestamp');
|
|
179
|
+
if (Math.abs(now - ts) > FRESHNESS_MS) return fail('stale timestamp');
|
|
180
|
+
|
|
181
|
+
const grant = this.grants().find((g) => g.key === identity.id);
|
|
182
|
+
if (!grant) return fail('no grant for key');
|
|
183
|
+
if (grant.expires && Date.parse(grant.expires) < now) return fail('grant expired');
|
|
184
|
+
|
|
185
|
+
const raw = b64urlToBuf(identity.id.slice('ed25519:'.length));
|
|
186
|
+
if (!raw || raw.length !== 32) return fail('malformed public key');
|
|
187
|
+
const sig = b64urlToBuf(identity.proof ?? '');
|
|
188
|
+
if (!sig || sig.length !== 64) return fail('malformed signature');
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const okSig = cryptoVerify(
|
|
192
|
+
null,
|
|
193
|
+
Buffer.from(observerStatement(hostHeader, identity.timestamp), 'utf8'),
|
|
194
|
+
ed25519PublicKey(raw),
|
|
195
|
+
sig,
|
|
196
|
+
);
|
|
197
|
+
if (!okSig) return fail('signature verify failed');
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return fail(`verify error: ${err instanceof Error ? err.message : err}`);
|
|
200
|
+
}
|
|
201
|
+
return { grant, scopes: new Set(grant.scopes) };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Sessions — short-lived bearer tokens minted after WS auth so the SPA can
|
|
207
|
+
// hit /debug/* and /healthz over plain HTTP. The WS is the authentication
|
|
208
|
+
// event; HTTP rides it.
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
const SESSION_TTL_MS = 12 * 60 * 60_000;
|
|
212
|
+
|
|
213
|
+
export class ObserverSessions {
|
|
214
|
+
private sessions = new Map<string, { scopes: Set<ObserverScope>; expiresAt: number }>();
|
|
215
|
+
|
|
216
|
+
mint(scopes: Set<ObserverScope>): string {
|
|
217
|
+
// Opportunistic sweep — the map stays tiny (one entry per WS auth).
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
for (const [t, s] of this.sessions) if (s.expiresAt < now) this.sessions.delete(t);
|
|
220
|
+
const token = randomBytes(32).toString('hex');
|
|
221
|
+
this.sessions.set(token, { scopes, expiresAt: now + SESSION_TTL_MS });
|
|
222
|
+
return token;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
lookup(token: string | null | undefined): Set<ObserverScope> | null {
|
|
226
|
+
if (!token) return null;
|
|
227
|
+
const s = this.sessions.get(token);
|
|
228
|
+
if (!s || s.expiresAt < Date.now()) return null;
|
|
229
|
+
return s.scopes;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Extract the observer session token from a request (cookie `fkm_obs`). */
|
|
234
|
+
export function sessionTokenFromRequest(req: Request): string | null {
|
|
235
|
+
const cookie = req.headers.get('cookie');
|
|
236
|
+
if (!cookie) return null;
|
|
237
|
+
const m = /(?:^|;\s*)fkm_obs=([a-f0-9]{64})/.exec(cookie);
|
|
238
|
+
return m ? m[1]! : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Scope filtering — pure projections of wire data through a grant's mask
|
|
243
|
+
// (docs/observability.md §4). Kept here (not in the module) so they are
|
|
244
|
+
// unit-testable without the webui's process-level server singleton.
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
import type { WelcomeMessage, WelcomeMessageEntry } from '../web/protocol.js';
|
|
248
|
+
|
|
249
|
+
/** Which scope a trace event requires. Token deltas map by blockType;
|
|
250
|
+
* ops/mcpl lifecycle → 'ops'; usage → 'health'; the rest is conversation
|
|
251
|
+
* lifecycle → 'messages'. */
|
|
252
|
+
export function traceRequiredScope(event: { type: string }): ObserverScope {
|
|
253
|
+
const t = event.type;
|
|
254
|
+
if (t.startsWith('ops:') || t.startsWith('mcpl:')) return 'ops';
|
|
255
|
+
if (t === 'usage:updated') return 'health';
|
|
256
|
+
if (t === 'inference:tokens' || t === 'inference:content_block') {
|
|
257
|
+
const bt = (event as { blockType?: string }).blockType;
|
|
258
|
+
if (bt === 'thinking') return 'thinking';
|
|
259
|
+
if (bt === 'tool_call' || bt === 'tool_result') return 'tools';
|
|
260
|
+
return 'messages';
|
|
261
|
+
}
|
|
262
|
+
if (t.startsWith('tool:')) return 'tools';
|
|
263
|
+
return 'messages';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Project a wire entry through a scope mask: null when the observer may not
|
|
267
|
+
* see messages at all; otherwise thinking/tool blocks are elided per scope.
|
|
268
|
+
* Filtering, not rewriting — the wire is already typed blocks. */
|
|
269
|
+
export function filterEntryForScopes(
|
|
270
|
+
entry: WelcomeMessageEntry,
|
|
271
|
+
scopes: Set<ObserverScope>,
|
|
272
|
+
): WelcomeMessageEntry | null {
|
|
273
|
+
if (!scopes.has('messages')) return null;
|
|
274
|
+
if (scopes.has('thinking') && scopes.has('tools')) return entry;
|
|
275
|
+
const blocks = entry.blocks.filter((b) => {
|
|
276
|
+
if ((b.kind === 'thinking' || b.kind === 'redacted_thinking') && !scopes.has('thinking')) return false;
|
|
277
|
+
if ((b.kind === 'tool_use' || b.kind === 'tool_result') && !scopes.has('tools')) return false;
|
|
278
|
+
return true;
|
|
279
|
+
});
|
|
280
|
+
return { ...entry, blocks };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Project a welcome through an observer's scope mask. Without 'messages'
|
|
284
|
+
* the entire conversation payload (entries + agent trees, which carry
|
|
285
|
+
* streaming buffers) is emptied — a health/ops observer like the fleet hub
|
|
286
|
+
* gets structure and usage, never content. */
|
|
287
|
+
export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>): WelcomeMessage {
|
|
288
|
+
const emptyTree = { asOfTs: Date.now(), nodes: [], callIdIndex: {} };
|
|
289
|
+
if (!scopes.has('messages')) {
|
|
290
|
+
return {
|
|
291
|
+
...welcome,
|
|
292
|
+
messages: [],
|
|
293
|
+
history: { startIndex: welcome.history.totalCount, totalCount: welcome.history.totalCount },
|
|
294
|
+
localTree: emptyTree,
|
|
295
|
+
childTrees: [],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
...welcome,
|
|
300
|
+
messages: welcome.messages
|
|
301
|
+
.map((e) => filterEntryForScopes(e, scopes))
|
|
302
|
+
.filter((e): e is WelcomeMessageEntry => e !== null),
|
|
303
|
+
// Agent trees replay tool calls + streaming thinking; only full
|
|
304
|
+
// interiority scopes get them.
|
|
305
|
+
...(scopes.has('thinking') && scopes.has('tools')
|
|
306
|
+
? {}
|
|
307
|
+
: { localTree: emptyTree, childTrees: [] }),
|
|
308
|
+
};
|
|
309
|
+
}
|
package/src/web/protocol.ts
CHANGED
|
@@ -364,8 +364,35 @@ export type WebUiServerMessage =
|
|
|
364
364
|
| WorkspaceFileMessage
|
|
365
365
|
| HistoryPageMessage
|
|
366
366
|
| MessageAppendedMessage
|
|
367
|
+
| ObserverAuthRequiredMessage
|
|
368
|
+
| ObserverAckMessage
|
|
367
369
|
| ErrorMessage;
|
|
368
370
|
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Observer identity (docs/observability.md M2) — additive to protocol v1.
|
|
373
|
+
// Basic-auth clients never see these frames; key-authenticated observers
|
|
374
|
+
// authenticate in-band over the WS before any data flows.
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
/** Sent to a connection that upgraded without basic auth (allowed only when
|
|
378
|
+
* observer grants exist): the client must reply with observer-hello before
|
|
379
|
+
* anything else is sent. `host` is what the server will verify the signed
|
|
380
|
+
* statement against — the Host header of the upgrade request. */
|
|
381
|
+
export interface ObserverAuthRequiredMessage {
|
|
382
|
+
type: 'observer-auth-required';
|
|
383
|
+
host: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Successful observer authentication. `sessionToken` is a short-lived
|
|
387
|
+
* bearer for HTTP routes (set as the `fkm_obs` cookie by the SPA);
|
|
388
|
+
* `scopes` is the grant's mask — the SPA adapts its UI to it. */
|
|
389
|
+
export interface ObserverAckMessage {
|
|
390
|
+
type: 'observer-ack';
|
|
391
|
+
scopes: string[];
|
|
392
|
+
sessionToken: string;
|
|
393
|
+
label: string;
|
|
394
|
+
}
|
|
395
|
+
|
|
369
396
|
// ---------------------------------------------------------------------------
|
|
370
397
|
// Client → Server
|
|
371
398
|
// ---------------------------------------------------------------------------
|
|
@@ -522,8 +549,26 @@ export interface RequestHistoryMessage {
|
|
|
522
549
|
limit?: number;
|
|
523
550
|
}
|
|
524
551
|
|
|
552
|
+
/** In-band observer authentication (docs/observability.md §3): the client
|
|
553
|
+
* signs `connectome-observer|v1|<host>|<timestamp>` with its ed25519 key.
|
|
554
|
+
* Challenge-less — the Host binding + freshness window replace a nonce. */
|
|
555
|
+
export interface ObserverHelloMessage {
|
|
556
|
+
type: 'observer-hello';
|
|
557
|
+
identity: {
|
|
558
|
+
scheme: 'ed25519';
|
|
559
|
+
/** `ed25519:<base64url raw 32-byte public key>` */
|
|
560
|
+
id: string;
|
|
561
|
+
/** base64url signature over the bound statement. */
|
|
562
|
+
proof: string;
|
|
563
|
+
/** ISO timestamp used in the statement (±5 min freshness). */
|
|
564
|
+
timestamp: string;
|
|
565
|
+
displayName?: string;
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
525
569
|
export type WebUiClientMessage =
|
|
526
570
|
| UserMessageMessage
|
|
571
|
+
| ObserverHelloMessage
|
|
527
572
|
| RequestHistoryMessage
|
|
528
573
|
| CommandMessage
|
|
529
574
|
| RouteToChildMessage
|
|
@@ -565,6 +610,13 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
|
|
|
565
610
|
return true;
|
|
566
611
|
case 'user-message':
|
|
567
612
|
return typeof v.content === 'string';
|
|
613
|
+
case 'observer-hello': {
|
|
614
|
+
const id = v.identity as Record<string, unknown> | undefined;
|
|
615
|
+
return !!id && id.scheme === 'ed25519'
|
|
616
|
+
&& typeof id.id === 'string'
|
|
617
|
+
&& typeof id.proof === 'string'
|
|
618
|
+
&& typeof id.timestamp === 'string';
|
|
619
|
+
}
|
|
568
620
|
case 'command':
|
|
569
621
|
return typeof v.command === 'string'
|
|
570
622
|
&& (v.corrId === undefined || typeof v.corrId === 'string');
|
|
@@ -45,8 +45,11 @@ describe('FleetModule subscribe union e2e', () => {
|
|
|
45
45
|
let tmpDir: string;
|
|
46
46
|
let recipePath: string;
|
|
47
47
|
let fleet: FleetModule;
|
|
48
|
+
let previousApiKey: string | undefined;
|
|
48
49
|
|
|
49
50
|
beforeAll(async () => {
|
|
51
|
+
previousApiKey = process.env.ANTHROPIC_API_KEY;
|
|
52
|
+
process.env.ANTHROPIC_API_KEY = previousApiKey || 'sk-test-fleet-subscribe-union';
|
|
50
53
|
tmpDir = mkdtempSync(join(tmpdir(), 'fkm-sub-union-'));
|
|
51
54
|
recipePath = join(tmpDir, 'recipe.json');
|
|
52
55
|
writeFileSync(recipePath, JSON.stringify(NARROW_RECIPE), 'utf-8');
|
|
@@ -68,6 +71,8 @@ describe('FleetModule subscribe union e2e', () => {
|
|
|
68
71
|
} catch { /* noop */ }
|
|
69
72
|
await new Promise((r) => setTimeout(r, 500));
|
|
70
73
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
|
74
|
+
if (previousApiKey === undefined) delete process.env.ANTHROPIC_API_KEY;
|
|
75
|
+
else process.env.ANTHROPIC_API_KEY = previousApiKey;
|
|
71
76
|
});
|
|
72
77
|
|
|
73
78
|
test('narrow recipe subscription gets reducer-required events forced in by FleetModule', async () => {
|
|
@@ -51,6 +51,8 @@ describe('FleetTreeAggregator — e2e against headless child', () => {
|
|
|
51
51
|
let aggregator: FleetTreeAggregator;
|
|
52
52
|
|
|
53
53
|
beforeAll(async () => {
|
|
54
|
+
// Children validate credentials during startup but never infer in this test.
|
|
55
|
+
process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || 'sk-test-fleet-aggregator';
|
|
54
56
|
tmpDir = mkdtempSync(join(tmpdir(), 'fkm-agg-e2e-'));
|
|
55
57
|
recipePath = join(tmpDir, 'recipe.json');
|
|
56
58
|
writeFileSync(recipePath, JSON.stringify(CHILD_RECIPE), 'utf-8');
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { buildContextCoverageSnapshot } from '../src/modules/web-ui-module.js';
|
|
3
|
+
|
|
4
|
+
function contextManager(strategy: Record<string, unknown>) {
|
|
5
|
+
return {
|
|
6
|
+
currentBranch: () => ({ name: 'test-branch' }),
|
|
7
|
+
getStrategy: () => strategy,
|
|
8
|
+
getPendingWork: () => ({ description: 'Compressing chunk 2' }),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('context coverage snapshot', () => {
|
|
13
|
+
test('reports unbounded summary depth, selected depth, and queued work without text', () => {
|
|
14
|
+
const summaries = [
|
|
15
|
+
{ id: 'L1-1', level: 1, content: 'secret one', tokens: 100, mergedInto: 'L2-3' },
|
|
16
|
+
{ id: 'L1-2', level: 1, content: 'secret two', tokens: 120, mergedInto: 'L2-3' },
|
|
17
|
+
{ id: 'L2-3', level: 2, content: 'secret parent', tokens: 80, mergedInto: 'L4-4' },
|
|
18
|
+
{ id: 'L4-4', level: 4, content: 'secret root', tokens: 40 },
|
|
19
|
+
];
|
|
20
|
+
const chunks = [
|
|
21
|
+
{ index: 0, tokens: 300, compressed: true, summaryId: 'L1-1', messages: [{ id: 'm1' }, { id: 'm2' }] },
|
|
22
|
+
{ index: 1, tokens: 200, compressed: true, summaryId: 'L1-2', messages: [{ id: 'm3' }] },
|
|
23
|
+
{ index: 2, tokens: 250, compressed: false, messages: [{ id: 'm4' }] },
|
|
24
|
+
];
|
|
25
|
+
const snapshot = buildContextCoverageSnapshot('fable', contextManager({
|
|
26
|
+
summaries,
|
|
27
|
+
chunks,
|
|
28
|
+
compressionQueue: [2],
|
|
29
|
+
mergeQueue: [{ level: 3, sourceIds: ['L2-a', 'L2-b'] }],
|
|
30
|
+
resolutions: new Map([['m1', 4], ['m2', 2], ['m3', 1]]),
|
|
31
|
+
pendingCompression: Promise.resolve(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
expect(snapshot.branch).toBe('test-branch');
|
|
35
|
+
expect(snapshot.levels.map(level => level.level)).toEqual([1, 2, 4]);
|
|
36
|
+
expect(snapshot.chunks[0]).toMatchObject({ maxLevel: 4, selectedMin: 2, selectedMax: 4 });
|
|
37
|
+
expect(snapshot.chunks[2]).toMatchObject({ maxLevel: 0, queued: true });
|
|
38
|
+
expect(snapshot.queue).toMatchObject({
|
|
39
|
+
inFlight: true,
|
|
40
|
+
pending: 'Compressing chunk 2',
|
|
41
|
+
l1: [2],
|
|
42
|
+
merges: [{ targetLevel: 3, sourceCount: 2 }],
|
|
43
|
+
});
|
|
44
|
+
expect(JSON.stringify(snapshot)).not.toContain('secret');
|
|
45
|
+
expect(JSON.stringify(snapshot)).not.toContain('content');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('stops coverage traversal at a dangling parent', () => {
|
|
49
|
+
const snapshot = buildContextCoverageSnapshot('fable', contextManager({
|
|
50
|
+
summaries: [{ id: 'L1-1', level: 1, tokens: 100, mergedInto: 'missing-L2' }],
|
|
51
|
+
chunks: [{ index: 0, tokens: 200, compressed: true, summaryId: 'L1-1', messages: [{ id: 'm1' }] }],
|
|
52
|
+
compressionQueue: [],
|
|
53
|
+
mergeQueue: [],
|
|
54
|
+
resolutions: new Map(),
|
|
55
|
+
pendingCompression: null,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
expect(snapshot.chunks[0].maxLevel).toBe(1);
|
|
59
|
+
expect(snapshot.queue.inFlight).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|