@volter/supercode-teams 0.3.172 → 0.3.174
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/machine.mjs +74 -6
- package/package.json +7 -2
- package/session-read-client.d.ts +11 -0
- package/session-read-client.mjs +109 -0
- package/session-read-worker.mjs +51 -0
- package/session-runtime-state.mjs +110 -0
package/machine.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { checkRemoteClose } from './remote-close.mjs';
|
|
|
39
39
|
import { SqliteMailStore } from './mail/sqlite-store.mjs';
|
|
40
40
|
import { SessionIndexOwner, requireSessionIndexRuntime } from './session-index-owner.mjs';
|
|
41
41
|
import { SessionIndexViews, SESSION_INDEX_METHODS, SESSION_INDEX_CAPABILITY } from './session-index-views.mjs';
|
|
42
|
+
import { SessionReadClient, SESSION_READ_METHODS, SESSION_READ_EVENT, SESSION_READ_CAPABILITY, parseSessionWire, stringifySessionWire } from './session-read-client.mjs';
|
|
42
43
|
import { projectLiveDescriptors, addDiscoveryActivities, displayRecordLive } from './session-index-live.mjs';
|
|
43
44
|
import { MailReads, panePrompt } from './mail/reads.mjs';
|
|
44
45
|
import { render } from './mail/envelope.mjs';
|
|
@@ -1271,7 +1272,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
1271
1272
|
this.#stopMailWorkers();
|
|
1272
1273
|
const closingViews = this.indexViews?.close();
|
|
1273
1274
|
const closingIndex = this.sessionIndex?.close();
|
|
1274
|
-
|
|
1275
|
+
const closingReads = [...(this.sessionReadClients ?? [])].map((client) => client.close());
|
|
1276
|
+
await Promise.all([closingViews, closingIndex, ...closingReads]);
|
|
1275
1277
|
this.mailStore?.stop();
|
|
1276
1278
|
this.stoppingServe = true;
|
|
1277
1279
|
clearTimeout(this.serveRestart);
|
|
@@ -1380,6 +1382,15 @@ export class TeamsMachine extends MachineCore {
|
|
|
1380
1382
|
/** One `harness.v1` request of the daemon's own, to its `harness serve`: the
|
|
1381
1383
|
* connection id INTERNAL_CONNECTION marks the answer as ours. */
|
|
1382
1384
|
#serveRequest(method, params, timeoutMs = 3000) {
|
|
1385
|
+
if (SESSION_READ_METHODS.includes(method)) {
|
|
1386
|
+
if (this.stopping) return Promise.reject(new Error('this machine daemon is stopping'));
|
|
1387
|
+
if (!this.sessionReads) {
|
|
1388
|
+
const client = this.#newSessionReads(); this.sessionReads = client;
|
|
1389
|
+
client.on('notification', (frame) => this.emit('internal-notification', frame));
|
|
1390
|
+
client.on('ended', (error) => { if (this.sessionReads === client) this.sessionReads = null; this.emit('serve-stderr', `session read service ended: ${error.message}\n`); });
|
|
1391
|
+
}
|
|
1392
|
+
return this.sessionReads.call(method, params, { timeoutMs });
|
|
1393
|
+
}
|
|
1383
1394
|
if (SESSION_INDEX_METHODS.includes(method)) return new Promise((resolve, reject) => {
|
|
1384
1395
|
let ended = false;
|
|
1385
1396
|
const timer = setTimeout(() => { ended = true; reject(new Error(`${method} timed out`)); }, timeoutMs);
|
|
@@ -1447,9 +1458,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
1447
1458
|
const method = entry.harnessRequests?.get(original);
|
|
1448
1459
|
entry.harnessRequests?.delete(original);
|
|
1449
1460
|
if (method === 'harness.v1.capabilities' && message.result) message.result = { ...message.result,
|
|
1450
|
-
methods: [...new Set([...(message.result.methods ?? []), ...SESSION_INDEX_METHODS])],
|
|
1451
|
-
notifications: [...new Set([...(message.result.notifications ?? []), 'harness.v1.sessions.index_event'])],
|
|
1452
|
-
session_index: SESSION_INDEX_CAPABILITY };
|
|
1461
|
+
methods: [...new Set([...(message.result.methods ?? []), ...SESSION_INDEX_METHODS, ...SESSION_READ_METHODS])],
|
|
1462
|
+
notifications: [...new Set([...(message.result.notifications ?? []), 'harness.v1.sessions.index_event', SESSION_READ_EVENT])],
|
|
1463
|
+
session_index: SESSION_INDEX_CAPABILITY, session_read: SESSION_READ_CAPABILITY };
|
|
1453
1464
|
// Learn routing keys from results so later notifications find their owner.
|
|
1454
1465
|
if (message.result && typeof message.result === 'object') {
|
|
1455
1466
|
for (const key of ['subscription', 'connection', 'runtime_id', 'subscription_id']) {
|
|
@@ -1527,7 +1538,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1527
1538
|
}
|
|
1528
1539
|
if (stream.closed) return;
|
|
1529
1540
|
let message;
|
|
1530
|
-
try { message =
|
|
1541
|
+
try { message = parseSessionWire(line); } catch { return; }
|
|
1531
1542
|
const asked = message.id !== undefined && message.id !== null ? message.id : null;
|
|
1532
1543
|
if (SESSION_INDEX_METHODS.includes(message.method)) {
|
|
1533
1544
|
// A cold read on one owner source cannot hold this stream's
|
|
@@ -1535,6 +1546,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
1535
1546
|
void this.#answerIndex(entry, stream, message);
|
|
1536
1547
|
return;
|
|
1537
1548
|
}
|
|
1549
|
+
if (SESSION_READ_METHODS.includes(message.method)) {
|
|
1550
|
+
void this.#answerRead(entry, stream, message);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1538
1553
|
if (asked !== null) message.id = joinId(index, message.id);
|
|
1539
1554
|
if (asked !== null && message.method === 'harness.v1.runtimes.close' && !message.params?.connection && typeof message.params?.runtime_id === 'string' && this.closeOwnedRuntime) {
|
|
1540
1555
|
try {
|
|
@@ -1560,7 +1575,60 @@ export class TeamsMachine extends MachineCore {
|
|
|
1560
1575
|
}).catch(() => { if (!stream.closed) stream.close('machine authority could not be verified'); });
|
|
1561
1576
|
}
|
|
1562
1577
|
});
|
|
1563
|
-
stream.on('close', () => {
|
|
1578
|
+
stream.on('close', () => {
|
|
1579
|
+
this.indexViews?.releaseConnection(stream);
|
|
1580
|
+
void entry.sessionReads?.close().catch((error) => this.emit('serve-stderr', `${error.message}\n`)); entry.sessionReads = null;
|
|
1581
|
+
entry.harness = null; entry.harnessRequests.clear(); entry.harnessNative = false;
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
#newSessionReads() {
|
|
1586
|
+
const client = new SessionReadClient({ env: this.env, cwd: process.cwd() });
|
|
1587
|
+
(this.sessionReadClients ??= new Set()).add(client);
|
|
1588
|
+
client.on('closed', () => this.sessionReadClients.delete(client));
|
|
1589
|
+
client.on('shutdown-error', (error) => this.emit('serve-stderr', `${error.message}\n`));
|
|
1590
|
+
return client;
|
|
1591
|
+
}
|
|
1592
|
+
#readOutput(entry, stream, frame) {
|
|
1593
|
+
if (stream.closed || this.stopping) return Promise.resolve(false);
|
|
1594
|
+
const line = stringifySessionWire(frame), bytes = Buffer.byteLength(line);
|
|
1595
|
+
entry.sessionReadBuffered = (entry.sessionReadBuffered ?? 0) + bytes;
|
|
1596
|
+
if (entry.sessionReadBuffered > 16 * 1024 * 1024 && frame.method === SESSION_READ_EVENT) {
|
|
1597
|
+
entry.sessionReadBuffered -= bytes; stream.close('session read notifications exceeded the stream buffer'); return Promise.resolve(false);
|
|
1598
|
+
}
|
|
1599
|
+
entry.sessionReadOutput = (entry.sessionReadOutput ?? Promise.resolve()).then(async () => {
|
|
1600
|
+
if (stream.closed || this.stopping) return false;
|
|
1601
|
+
if (entry.verify) {
|
|
1602
|
+
const fresh = await entry.verify();
|
|
1603
|
+
if (fresh?.class !== 'operator') { stream.close('no live operator grant'); return false; }
|
|
1604
|
+
entry.admitted = fresh;
|
|
1605
|
+
}
|
|
1606
|
+
if (stream.closed || entry.admitted?.class !== 'operator') return false;
|
|
1607
|
+
writeLine(stream, line); return true;
|
|
1608
|
+
}).catch(() => { if (!stream.closed) stream.close('machine authority could not be verified'); return false; })
|
|
1609
|
+
.finally(() => { entry.sessionReadBuffered -= bytes; });
|
|
1610
|
+
return entry.sessionReadOutput;
|
|
1611
|
+
}
|
|
1612
|
+
async #answerRead(entry, stream, message) {
|
|
1613
|
+
const asked = message.id !== undefined && message.id !== null ? message.id : null;
|
|
1614
|
+
if (message.jsonrpc !== '2.0' || asked !== null && typeof asked !== 'string' && !Number.isFinite(asked)) {
|
|
1615
|
+
if (!stream.closed) writeLine(stream, JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'invalid JSON-RPC request' } }));
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
try {
|
|
1619
|
+
if (stream.closed || this.stopping) return;
|
|
1620
|
+
if (!entry.sessionReads) {
|
|
1621
|
+
const client = this.#newSessionReads(); entry.sessionReads = client;
|
|
1622
|
+
client.on('notification', (frame) => { void this.#readOutput(entry, stream, frame); });
|
|
1623
|
+
client.on('ended', (error) => { if (!stream.closed) stream.close(`session read service ended: ${error.message}`); });
|
|
1624
|
+
}
|
|
1625
|
+
const client = entry.sessionReads, answer = await client.submit(message);
|
|
1626
|
+
if (asked !== null && await this.#readOutput(entry, stream, answer)
|
|
1627
|
+
&& message.method === 'harness.v1.sessions.follow' && answer.result?.subscription) client.activate(answer.result.subscription);
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
if (asked !== null && !stream.closed) await this.#readOutput(entry, stream, { jsonrpc: '2.0', id: asked,
|
|
1630
|
+
error: { code: error.code ?? -32000, message: error.message } });
|
|
1631
|
+
}
|
|
1564
1632
|
}
|
|
1565
1633
|
|
|
1566
1634
|
async #indexClaudeName(query, name) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.174",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"types": "./session-index-client.d.ts",
|
|
13
13
|
"default": "./session-index-client.mjs"
|
|
14
14
|
},
|
|
15
|
+
"./session-read-client": {
|
|
16
|
+
"types": "./session-read-client.d.ts",
|
|
17
|
+
"default": "./session-read-client.mjs"
|
|
18
|
+
},
|
|
15
19
|
"./live-sessions": "./live-sessions.mjs",
|
|
16
20
|
"./codex-hooks": {
|
|
17
21
|
"types": "./codex-hooks.d.ts",
|
|
@@ -57,6 +61,7 @@
|
|
|
57
61
|
"workspace-client.d.ts",
|
|
58
62
|
"codex-hooks.d.ts",
|
|
59
63
|
"session-index-client.d.ts",
|
|
64
|
+
"session-read-client.d.ts",
|
|
60
65
|
"porthole-hook.cjs",
|
|
61
66
|
"bin",
|
|
62
67
|
"server",
|
|
@@ -79,7 +84,7 @@
|
|
|
79
84
|
"@volter/supercode-frontend": "^0.2.2",
|
|
80
85
|
"@volter/supercode-harness-sdk": "^0.3.58",
|
|
81
86
|
"@volter/supercode-health": "^0.1.0",
|
|
82
|
-
"@volter/supercode-interchange": "0.1.
|
|
87
|
+
"@volter/supercode-interchange": "0.1.3",
|
|
83
88
|
"@volter/supercode-terminal": "^0.2.54",
|
|
84
89
|
"@volter/supercode-trace": "^0.1.0",
|
|
85
90
|
"@volter/teams": "0.2.45",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { IndexRpcFrame } from './session-index-client.js';
|
|
3
|
+
export { parseSessionWire, stringifySessionWire, SESSION_READ_METHODS, SESSION_READ_EVENT, SESSION_READ_CAPABILITY } from '@volter/supercode-interchange/session-read';
|
|
4
|
+
/** Caller-owned worker lanes; never forwards a read to native or to a daemon. */
|
|
5
|
+
export class SessionReadClient extends EventEmitter {
|
|
6
|
+
constructor(options?: { env?: Record<string, string | undefined>; cwd?: string; pollMs?: number | string });
|
|
7
|
+
submit(request: IndexRpcFrame, onAnswer?: ((answer: IndexRpcFrame) => void) | null): Promise<IndexRpcFrame>;
|
|
8
|
+
call(method: string, params?: unknown, options?: { timeoutMs?: number | null }): Promise<unknown>;
|
|
9
|
+
activate(subscription: string): void;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { Worker } from 'node:worker_threads';
|
|
3
|
+
import { parseSessionWire, stringifySessionWire, SESSION_READ_METHODS, SESSION_READ_EVENT, SESSION_READ_CAPABILITY } from '@volter/supercode-interchange/session-read';
|
|
4
|
+
import { SessionRuntimeStates } from './session-runtime-state.mjs';
|
|
5
|
+
|
|
6
|
+
export { parseSessionWire, stringifySessionWire, SESSION_READ_METHODS, SESSION_READ_EVENT, SESSION_READ_CAPABILITY };
|
|
7
|
+
const hasId = (request) => request.id !== undefined && request.id !== null;
|
|
8
|
+
|
|
9
|
+
export class SessionReadClient extends EventEmitter {
|
|
10
|
+
#env; #cwd; #pollMs; #workers = new Map(); #pending = new Map(); #sources = new Map();
|
|
11
|
+
#next = 1; #subscription = 1; #states; #timer; #statePolling = false; #closed = false; #stopping = null;
|
|
12
|
+
constructor({ env = process.env, cwd = process.cwd(), pollMs = 500 } = {}) {
|
|
13
|
+
super(); this.#env = { ...env }; this.#cwd = cwd;
|
|
14
|
+
this.#pollMs = Math.min(0x7fffffff, Math.max(1, Number(pollMs)));
|
|
15
|
+
this.#states = new SessionRuntimeStates(this.#env);
|
|
16
|
+
this.#timer = setInterval(() => { void this.#pollStates(); }, this.#pollMs); this.#timer.unref();
|
|
17
|
+
}
|
|
18
|
+
#worker(lane) {
|
|
19
|
+
if (this.#closed) throw new Error('session-read client is closed');
|
|
20
|
+
let worker = this.#workers.get(lane); if (worker) return worker;
|
|
21
|
+
worker = new Worker(new URL('./session-read-worker.mjs', import.meta.url), { name: `supercode-session-${lane}`, env: this.#env,
|
|
22
|
+
workerData: { lane, env: this.#env, cwd: this.#cwd, pollMs: this.#pollMs } });
|
|
23
|
+
this.#workers.set(lane, worker);
|
|
24
|
+
worker.on('message', (frame) => {
|
|
25
|
+
if (this.#closed) return;
|
|
26
|
+
try {
|
|
27
|
+
if (frame.fatal) throw new Error(frame.fatal);
|
|
28
|
+
if (frame.notification) {
|
|
29
|
+
const notification = parseSessionWire(frame.notification);
|
|
30
|
+
if (this.#sources.get(notification.params?.subscription)?.active) this.emit('notification', notification);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const held = this.#pending.get(frame.id); if (!held || held.worker !== worker) return;
|
|
34
|
+
const answer = frame.error ? { jsonrpc: '2.0', id: held.request.id, error: frame.error }
|
|
35
|
+
: { jsonrpc: '2.0', id: held.request.id, result: parseSessionWire(frame.result) };
|
|
36
|
+
this.#pending.delete(frame.id);
|
|
37
|
+
if (answer.result && hasId(held.request) && held.request.method === 'harness.v1.sessions.follow') {
|
|
38
|
+
const locator = held.request.params.locator;
|
|
39
|
+
this.#sources.set(answer.result.subscription, { harness: locator.harness, session_id: locator.session_id, reported: null, active: false });
|
|
40
|
+
}
|
|
41
|
+
if (answer.result?.removed && held.request.method === 'harness.v1.sessions.unfollow') this.#sources.delete(held.request.params.subscription);
|
|
42
|
+
try {
|
|
43
|
+
held.onAnswer?.(answer);
|
|
44
|
+
if (held.onAnswer && held.request.method === 'harness.v1.sessions.follow' && answer.result?.subscription && hasId(held.request)) this.activate(answer.result.subscription);
|
|
45
|
+
held.resolve(answer);
|
|
46
|
+
} catch (error) { held.reject(error); }
|
|
47
|
+
} catch (error) { this.#failed(error); }
|
|
48
|
+
});
|
|
49
|
+
worker.on('error', (error) => this.#failed(error));
|
|
50
|
+
worker.on('exit', (code) => { if (!this.#closed) this.#failed(new Error(`session ${lane} worker exited (${code})`)); });
|
|
51
|
+
return worker;
|
|
52
|
+
}
|
|
53
|
+
activate(subscription) {
|
|
54
|
+
const source = this.#sources.get(subscription); if (!source || this.#closed || source.active) return;
|
|
55
|
+
source.active = true; this.#worker('follow').postMessage({ activate: subscription });
|
|
56
|
+
}
|
|
57
|
+
async call(method, params = {}, { timeoutMs = null } = {}) {
|
|
58
|
+
let timer;
|
|
59
|
+
try {
|
|
60
|
+
const work = this.submit({ jsonrpc: '2.0', id: 1, method, params });
|
|
61
|
+
const answer = timeoutMs == null ? await work : await Promise.race([work, new Promise((_, reject) => {
|
|
62
|
+
timer = setTimeout(() => { this.#failed(new Error(`${method} did not answer within ${timeoutMs} ms`)); reject(new Error(`${method} timed out`)); }, timeoutMs);
|
|
63
|
+
})]);
|
|
64
|
+
if (answer.error) throw Object.assign(new Error(answer.error.message), { code: answer.error.code, answered: true });
|
|
65
|
+
if (method === 'harness.v1.sessions.follow' && answer.result?.subscription) this.activate(answer.result.subscription);
|
|
66
|
+
return answer.result;
|
|
67
|
+
} finally { clearTimeout(timer); }
|
|
68
|
+
}
|
|
69
|
+
#failed(error) { if (this.#closed) return; void this.close().catch((failure) => this.emit('shutdown-error', failure)); this.emit('ended', error); }
|
|
70
|
+
submit(request, onAnswer) {
|
|
71
|
+
if (this.#closed) return Promise.reject(new Error('session-read client is closed'));
|
|
72
|
+
if (!SESSION_READ_METHODS.includes(request.method)) return Promise.reject(new Error(`unknown session-read method: ${request.method}`));
|
|
73
|
+
const worker = this.#worker(request.method === 'harness.v1.sessions.load' ? 'load' : 'follow'), id = this.#next++;
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
this.#pending.set(id, { worker, request, onAnswer, resolve, reject });
|
|
76
|
+
try { worker.postMessage({ id, method: request.method, params: stringifySessionWire(request.params ?? {}), retain: hasId(request),
|
|
77
|
+
subscription: `read-sub-${this.#subscription++}` }); }
|
|
78
|
+
catch (error) { this.#pending.delete(id); reject(error); }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async #pollStates() {
|
|
82
|
+
if (this.#closed || this.#statePolling || !this.#sources.size) return;
|
|
83
|
+
this.#statePolling = true;
|
|
84
|
+
try {
|
|
85
|
+
const selected = new Map([...this.#sources].filter(([, source]) => source.active)), states = await this.#states.read([...selected.values()]);
|
|
86
|
+
if (this.#closed) return;
|
|
87
|
+
for (const [subscription, source] of selected) {
|
|
88
|
+
if (this.#sources.get(subscription) !== source) continue;
|
|
89
|
+
const state = states.get(JSON.stringify([source.harness, source.session_id]));
|
|
90
|
+
if (state == null || state === source.reported) continue;
|
|
91
|
+
source.reported = state;
|
|
92
|
+
this.emit('notification', { jsonrpc: '2.0', method: SESSION_READ_EVENT, params: { subscription, event: { type: 'runtime_state', state } } });
|
|
93
|
+
}
|
|
94
|
+
} catch { /* a registry read failure establishes no transition */ }
|
|
95
|
+
finally { this.#statePolling = false; }
|
|
96
|
+
}
|
|
97
|
+
close() {
|
|
98
|
+
if (this.#stopping) return this.#stopping;
|
|
99
|
+
this.#closed = true; clearInterval(this.#timer); this.#states.close(); this.#sources.clear();
|
|
100
|
+
for (const held of this.#pending.values()) held.reject(new Error('session-read client closed'));
|
|
101
|
+
this.#pending.clear();
|
|
102
|
+
this.#stopping = Promise.all([...this.#workers.values()].map(async (worker) => {
|
|
103
|
+
let timer;
|
|
104
|
+
try { await Promise.race([worker.terminate(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`session-read worker ${worker.threadId} did not end after shutdown`)), 1500); })]); }
|
|
105
|
+
finally { clearTimeout(timer); }
|
|
106
|
+
})).then(() => { this.#workers.clear(); this.emit('closed'); });
|
|
107
|
+
return this.#stopping;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// One caller-owned worker per read lane; no binary, daemon, or credentials
|
|
2
|
+
// outside that caller's process. A long load cannot hold the stdio router.
|
|
3
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
4
|
+
import { loadSessionRead, openSessionReadFollower, parseSessionWire, stringifySessionWire, SESSION_READ_EVENT } from '@volter/supercode-interchange/session-read';
|
|
5
|
+
|
|
6
|
+
const followers = new Map(), context = { env: workerData.env, cwd: workerData.cwd };
|
|
7
|
+
let closing = false, queue = Promise.resolve(), polling = false;
|
|
8
|
+
const post = (frame) => { if (!closing) parentPort.postMessage(frame); };
|
|
9
|
+
const enqueue = (work) => { queue = queue.then(work).catch((error) => post({ fatal: error.message })); };
|
|
10
|
+
const timer = workerData.lane === 'follow' ? setInterval(() => {
|
|
11
|
+
if (closing || polling || !followers.size) return;
|
|
12
|
+
polling = true;
|
|
13
|
+
enqueue(async () => {
|
|
14
|
+
try {
|
|
15
|
+
for (const [subscription, { follower, active }] of followers) {
|
|
16
|
+
if (closing) return;
|
|
17
|
+
if (!active) continue;
|
|
18
|
+
let event;
|
|
19
|
+
try { event = await follower.poll(); }
|
|
20
|
+
catch (error) { event = { type: 'watch_error', recoverable: true, message: error.message }; }
|
|
21
|
+
if (event) post({ notification: stringifySessionWire({ jsonrpc: '2.0', method: SESSION_READ_EVENT, params: { subscription, event } }) });
|
|
22
|
+
}
|
|
23
|
+
} finally { polling = false; }
|
|
24
|
+
});
|
|
25
|
+
}, workerData.pollMs) : null;
|
|
26
|
+
|
|
27
|
+
parentPort.on('message', (request) => {
|
|
28
|
+
if (closing) return;
|
|
29
|
+
enqueue(async () => {
|
|
30
|
+
if (closing) return;
|
|
31
|
+
try {
|
|
32
|
+
if (request.activate) { const held = followers.get(request.activate); if (held) held.active = true; return; }
|
|
33
|
+
const params = parseSessionWire(request.params);
|
|
34
|
+
let result;
|
|
35
|
+
if (request.method === 'harness.v1.sessions.load') result = await loadSessionRead(params, context);
|
|
36
|
+
else if (request.method === 'harness.v1.sessions.follow') {
|
|
37
|
+
const follower = await openSessionReadFollower(params, context);
|
|
38
|
+
if (closing) { follower.close(); return; }
|
|
39
|
+
const initial = await follower.poll();
|
|
40
|
+
if (request.retain) followers.set(request.subscription, { follower, active: false }); else follower.close();
|
|
41
|
+
result = { subscription: request.subscription, initial };
|
|
42
|
+
} else if (request.method === 'harness.v1.sessions.unfollow') {
|
|
43
|
+
if (typeof params?.subscription !== 'string') throw Object.assign(new Error('subscription must be a string'), { rpcCode: -32602 });
|
|
44
|
+
const held = followers.get(params.subscription);
|
|
45
|
+
held?.follower.close(); result = { removed: followers.delete(params.subscription) };
|
|
46
|
+
} else throw Object.assign(new Error(`unknown session-read method: ${request.method}`), { rpcCode: -32601 });
|
|
47
|
+
post({ id: request.id, result: stringifySessionWire(result) });
|
|
48
|
+
} catch (error) { post({ id: request.id, error: { code: error.rpcCode ?? -32000, message: error.message } }); }
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
parentPort.on('close', () => { closing = true; clearInterval(timer); for (const { follower } of followers.values()) follower.close(); followers.clear(); });
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Read-only source lifecycle projection for sessions.follow. The retained
|
|
2
|
+
// registry still owns receipt retirement; this reader never removes a receipt
|
|
3
|
+
// or touches the runtime's controller, observer lease, or event stream.
|
|
4
|
+
import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { userInfo } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { request, Agent } from 'node:http';
|
|
8
|
+
import { lookup } from 'node:dns';
|
|
9
|
+
import { parseSessionWire } from '@volter/supercode-interchange/session-read';
|
|
10
|
+
|
|
11
|
+
const loopback = (address) => address === '127.0.0.1' || address === '::1';
|
|
12
|
+
function receiptHome(env) {
|
|
13
|
+
if (env.SUPERCODE_HOME) return env.SUPERCODE_HOME;
|
|
14
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, 'supercode');
|
|
15
|
+
let accountHome; try { accountHome = userInfo().homedir; } catch { accountHome = '.'; }
|
|
16
|
+
return join(env.HOME || (process.platform === 'win32' && env.USERPROFILE) || accountHome || '.', '.config', 'supercode');
|
|
17
|
+
}
|
|
18
|
+
const canonical = async (path) => { try { return await realpath(path); } catch { return path; } };
|
|
19
|
+
function live(pid) {
|
|
20
|
+
if (!Number.isInteger(pid) || pid < 1 || pid > 0xffffffff) return false;
|
|
21
|
+
try { process.kill(pid, 0); return true; } catch (error) { return process.platform !== 'win32' && error.code === 'EPERM'; }
|
|
22
|
+
}
|
|
23
|
+
function address(value) {
|
|
24
|
+
let url; try { url = new URL(value); } catch { return null; }
|
|
25
|
+
return url.protocol === 'http:' && !url.username && !url.password && !url.search && !url.hash
|
|
26
|
+
&& ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) ? url : null;
|
|
27
|
+
}
|
|
28
|
+
function localLookup(host, options, callback) {
|
|
29
|
+
lookup(host, options, (error, resolved, family) => {
|
|
30
|
+
if (error) return callback(error);
|
|
31
|
+
if (options.all) {
|
|
32
|
+
const local = resolved.filter((item) => loopback(item.address));
|
|
33
|
+
return local.length ? callback(null, local) : callback(new Error('runtime receipt hostname did not resolve to loopback'));
|
|
34
|
+
}
|
|
35
|
+
return loopback(resolved) ? callback(null, resolved, family) : callback(new Error('runtime receipt hostname did not resolve to loopback'));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class SessionRuntimeStates {
|
|
40
|
+
#directory; #agent = new Agent({ keepAlive: true, maxSockets: 8, lookup: localLookup });
|
|
41
|
+
#requests = new Set(); #closed = false; #nextId = 1;
|
|
42
|
+
constructor(env) { this.#directory = join(receiptHome(env), 'live-runtimes'); }
|
|
43
|
+
async #receipt(path) {
|
|
44
|
+
const receipt = parseSessionWire(await readFile(path, 'utf8'));
|
|
45
|
+
if (receipt?.schema !== 'supercode.live-runtime.v1' || !address(receipt.base_url) || !live(receipt.pid)
|
|
46
|
+
|| !/^[0-9a-f]{32}$/.test(receipt.receipt_id) || typeof receipt.runtime_session_id !== 'string'
|
|
47
|
+
|| typeof receipt.token !== 'string' || !receipt.token || typeof receipt.source?.harness !== 'string'
|
|
48
|
+
|| typeof receipt.source?.session_id !== 'string' || typeof receipt.source?.workspace !== 'string'
|
|
49
|
+
|| !Number.isFinite(receipt.created_at_ms) || receipt.created_at_ms < 0) throw new Error('not a live runtime receipt');
|
|
50
|
+
return receipt;
|
|
51
|
+
}
|
|
52
|
+
async #describe(receipt) {
|
|
53
|
+
if (this.#closed) throw new Error('runtime-state reader closed');
|
|
54
|
+
const url = address(`${receipt.base_url.replace(/\/+$/, '')}/rpc`); if (!url) throw new Error('invalid local runtime endpoint');
|
|
55
|
+
const id = this.#nextId++, body = JSON.stringify({ id, method: 'frontend.v2.describe', params: {} });
|
|
56
|
+
return new Promise((resolveResult, reject) => {
|
|
57
|
+
const req = request(url, { agent: this.#agent, method: 'POST', headers: {
|
|
58
|
+
authorization: `Bearer ${receipt.token}`, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body),
|
|
59
|
+
'x-supercode-client-id': `registry-${receipt.receipt_id}`, 'x-supercode-permissions': 'observe',
|
|
60
|
+
} }, (res) => {
|
|
61
|
+
// A receipt is authority only for its loopback endpoint. Never follow
|
|
62
|
+
// redirects or give its bearer to a different host.
|
|
63
|
+
if (res.statusCode < 200 || res.statusCode >= 300) { res.resume(); reject(new Error('local runtime refused its state probe')); return; }
|
|
64
|
+
let bytes = 0; const chunks = [];
|
|
65
|
+
res.on('data', (chunk) => { bytes += chunk.length; if (bytes > 1024 * 1024) req.destroy(new Error('runtime descriptor exceeds its read limit')); else chunks.push(chunk); });
|
|
66
|
+
res.on('error', reject);
|
|
67
|
+
res.on('end', () => {
|
|
68
|
+
try {
|
|
69
|
+
const frame = JSON.parse(Buffer.concat(chunks).toString('utf8')), descriptor = frame.result;
|
|
70
|
+
if (frame.id !== id || frame.error || descriptor?.schema_version !== 2 || typeof descriptor.session_id !== 'string'
|
|
71
|
+
|| !['connected', 'shutting_down'].includes(descriptor.connection_state)
|
|
72
|
+
|| !['idle', 'busy'].includes(descriptor.turn_state)) throw new Error('invalid runtime descriptor');
|
|
73
|
+
resolveResult(descriptor.connection_state === 'shutting_down' ? 'shutting_down' : descriptor.turn_state === 'busy' ? 'busy' : 'idle');
|
|
74
|
+
} catch (error) { reject(error); }
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
this.#requests.add(req);
|
|
78
|
+
const timer = setTimeout(() => req.destroy(new Error('local runtime state probe did not answer')), 2000);
|
|
79
|
+
req.once('close', () => { clearTimeout(timer); this.#requests.delete(req); });
|
|
80
|
+
req.once('error', reject); req.end(body);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** One receipt scan per poll, probing only followed source identities. */
|
|
84
|
+
async read(sources) {
|
|
85
|
+
if (this.#closed) throw new Error('runtime-state reader closed');
|
|
86
|
+
const keys = new Map(sources.map((source) => [JSON.stringify([source.harness, source.session_id]), 'persisted']));
|
|
87
|
+
if (!keys.size) return keys;
|
|
88
|
+
let names; try { names = await readdir(this.#directory); } catch { names = []; }
|
|
89
|
+
const receipts = [];
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
if (!name.endsWith('.json')) continue;
|
|
92
|
+
try { const receipt = await this.#receipt(join(this.#directory, name)); receipts.push(receipt); } catch { /* native registry discovery skips malformed/stale receipts */ }
|
|
93
|
+
}
|
|
94
|
+
receipts.sort((a, b) => b.created_at_ms - a.created_at_ms || Buffer.compare(Buffer.from(a.runtime_session_id), Buffer.from(b.runtime_session_id)));
|
|
95
|
+
for (const receipt of receipts) {
|
|
96
|
+
if (this.#closed) throw new Error('runtime-state reader closed');
|
|
97
|
+
const key = JSON.stringify([receipt.source.harness, receipt.source.session_id]);
|
|
98
|
+
if (keys.get(key) !== 'persisted') continue;
|
|
99
|
+
try {
|
|
100
|
+
const selected = await this.#receipt(join(this.#directory, `${receipt.receipt_id}.json`));
|
|
101
|
+
if (selected.receipt_id !== receipt.receipt_id || selected.source.harness !== receipt.source.harness
|
|
102
|
+
|| selected.source.session_id !== receipt.source.session_id
|
|
103
|
+
|| await canonical(selected.source.workspace) !== await canonical(receipt.source.workspace)) continue;
|
|
104
|
+
keys.set(key, await this.#describe(selected));
|
|
105
|
+
} catch { /* a failed probe is not a live runtime; try another matching registration */ }
|
|
106
|
+
}
|
|
107
|
+
return keys;
|
|
108
|
+
}
|
|
109
|
+
close() { this.#closed = true; for (const req of this.#requests) req.destroy(new Error('runtime-state reader closed')); this.#agent.destroy(); }
|
|
110
|
+
}
|