@dmsdc-ai/aigentry-telepty 0.4.3 → 0.4.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/CHANGELOG.md +385 -0
- package/README.md +17 -0
- package/cli.js +225 -7
- package/daemon.js +240 -3
- package/package.json +6 -4
- package/scripts/postinstall.js +94 -0
- package/src/bridge/j3-shim.js +264 -0
- package/src/bridge/supervisor-ipc.js +330 -0
- package/src/bridge/supervisor-launcher.js +193 -0
- package/src/config-file.js +86 -0
- package/src/lifecycle.js +237 -0
- package/src/prompt-symbol-registry.js +34 -4
- package/src/submit-gate.js +7 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// J3 shim — 0.3.x daemon-shape requests (inject/output/list) translated to
|
|
4
|
+
// NDJSON Frames against the per-session Rust supervisor.
|
|
5
|
+
//
|
|
6
|
+
// Scope (P2 per dispatch §Goal): inject / output (stream) / list only. Spawn /
|
|
7
|
+
// kill / delete remain on daemon.js for the migration window. Reads
|
|
8
|
+
// ~/.telepty/sessions/<sid>/manifest.json directly to discover supervisor-
|
|
9
|
+
// managed sessions (mirrors the supervisor binary's `--list` mode but avoids
|
|
10
|
+
// the subprocess hop for cli.js call sites).
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const { randomUUID } = require('node:crypto');
|
|
16
|
+
|
|
17
|
+
const { connect } = require('./supervisor-ipc');
|
|
18
|
+
|
|
19
|
+
const READY_STATUSES = new Set(['ready', 'draining']);
|
|
20
|
+
|
|
21
|
+
// Resolved lazily so tests (and operators) can redirect via
|
|
22
|
+
// TELEPTY_SESSIONS_DIR without re-requiring the module.
|
|
23
|
+
function sessionsRoot() {
|
|
24
|
+
return process.env.TELEPTY_SESSIONS_DIR || path.join(os.homedir(), '.telepty', 'sessions');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sessionDir(sid) {
|
|
28
|
+
return path.join(sessionsRoot(), sid);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function manifestPath(sid) {
|
|
32
|
+
return path.join(sessionDir(sid), 'manifest.json');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function socketPath(sid) {
|
|
36
|
+
return path.join(sessionDir(sid), 'supervisor.sock');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readManifest(sid) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(manifestPath(sid), 'utf8');
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
if (!parsed || typeof parsed !== 'object' || parsed.id !== sid) return null;
|
|
44
|
+
return parsed;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Locate a usable supervisor manifest for the session. Returns null when no
|
|
52
|
+
* manifest exists, the manifest is malformed, or the status is not in
|
|
53
|
+
* {ready, draining} — the caller should then fall back to the daemon.js path.
|
|
54
|
+
* @param {string} sid
|
|
55
|
+
* @returns {?object}
|
|
56
|
+
*/
|
|
57
|
+
function findSupervisorManifest(sid) {
|
|
58
|
+
const m = readManifest(sid);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
if (!m.ipc || typeof m.ipc.path !== 'string' || m.ipc.path.length === 0) return null;
|
|
61
|
+
if (!READY_STATUSES.has(m.status)) return null;
|
|
62
|
+
return m;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Inject text into a supervisor-managed session via the bridge.
|
|
67
|
+
*
|
|
68
|
+
* Request shape (0.3.x compat): (sessionId, prompt, options) where options
|
|
69
|
+
* carries optional `from`, `reply_to`, `reply_expected`, `idempotency_key`,
|
|
70
|
+
* `trace_id`, `connectTimeoutMs`, `errorWindowMs`.
|
|
71
|
+
*
|
|
72
|
+
* Response shape: `{ success: true, trace_id }` or
|
|
73
|
+
* `{ success: false, code, error, trace_id? }`. Inject is fire-and-forget on
|
|
74
|
+
* the wire (per supervisor.rs dispatch_ingest), so success = no error frame
|
|
75
|
+
* arrived within `errorWindowMs` (default 150ms — catches B3 trace_id /
|
|
76
|
+
* duplicate-op / shutting-down rejections).
|
|
77
|
+
*
|
|
78
|
+
* @param {string} sessionId
|
|
79
|
+
* @param {string} prompt
|
|
80
|
+
* @param {object} [options]
|
|
81
|
+
*/
|
|
82
|
+
async function inject(sessionId, prompt, options = {}) {
|
|
83
|
+
const manifest = findSupervisorManifest(sessionId);
|
|
84
|
+
if (!manifest) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
code: 'ERR_NO_SUPERVISOR',
|
|
88
|
+
error: `no supervisor manifest for session '${sessionId}'`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let client;
|
|
93
|
+
try {
|
|
94
|
+
client = await connect(manifest.ipc.path, {
|
|
95
|
+
connectTimeoutMs: options.connectTimeoutMs ?? 1500,
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
code: err.code || 'ERR_NOT_REACHABLE',
|
|
101
|
+
error: err.message,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const trace_id = options.trace_id || randomUUID();
|
|
106
|
+
const op_id = options.idempotency_key || trace_id;
|
|
107
|
+
const data = String(prompt ?? '');
|
|
108
|
+
|
|
109
|
+
const frame = {
|
|
110
|
+
kind: 'inject',
|
|
111
|
+
sid: sessionId,
|
|
112
|
+
trace_id,
|
|
113
|
+
op_id,
|
|
114
|
+
data,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await client.send(frame);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
try { await client.close(); } catch {}
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
code: err.code || 'ERR_BAD_FRAME',
|
|
124
|
+
error: err.message,
|
|
125
|
+
trace_id,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const errorWindowMs = options.errorWindowMs ?? 150;
|
|
130
|
+
const earlyError = await waitForCorrelatedError(client, sessionId, trace_id, errorWindowMs);
|
|
131
|
+
try { await client.close(); } catch {}
|
|
132
|
+
|
|
133
|
+
if (earlyError) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
code: earlyError.code || 'ERR_BAD_FRAME',
|
|
137
|
+
error: earlyError.data || 'supervisor rejected inject',
|
|
138
|
+
trace_id,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { success: true, trace_id };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function waitForCorrelatedError(client, sessionId, trace_id, timeoutMs) {
|
|
145
|
+
const ac = new AbortController();
|
|
146
|
+
const tm = setTimeout(() => ac.abort(), timeoutMs);
|
|
147
|
+
let found = null;
|
|
148
|
+
try {
|
|
149
|
+
for await (const frame of client.subscribe({ sid: sessionId, signal: ac.signal })) {
|
|
150
|
+
if (frame.kind === 'error' && frame.trace_id === trace_id) {
|
|
151
|
+
found = frame;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
clearTimeout(tm);
|
|
157
|
+
}
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Enumerate supervisor-managed sessions by scanning manifest files. Mirrors
|
|
163
|
+
* the binary's `--list` output but as in-process Node — avoids the subprocess
|
|
164
|
+
* hop for cli.js list path.
|
|
165
|
+
* @returns {Array<object>}
|
|
166
|
+
*/
|
|
167
|
+
function list() {
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
entries = fs.readdirSync(sessionsRoot(), { withFileTypes: true });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err && err.code === 'ENOENT') return [];
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const sessions = [];
|
|
176
|
+
for (const dirent of entries) {
|
|
177
|
+
if (!dirent.isDirectory()) continue;
|
|
178
|
+
const manifest = readManifest(dirent.name);
|
|
179
|
+
if (!manifest) continue;
|
|
180
|
+
// Surface only live sessions — tombstones (status: stopped|error) lack a
|
|
181
|
+
// usable IPC socket and would mislead callers using this for inject
|
|
182
|
+
// routing. Operators still see them via `telepty-supervisor-bin --list`.
|
|
183
|
+
if (!READY_STATUSES.has(manifest.status)) continue;
|
|
184
|
+
sessions.push(toSessionInfo(manifest));
|
|
185
|
+
}
|
|
186
|
+
return sessions;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function toSessionInfo(manifest) {
|
|
190
|
+
const info = {
|
|
191
|
+
id: manifest.id,
|
|
192
|
+
host: '127.0.0.1',
|
|
193
|
+
transport: 'supervisor',
|
|
194
|
+
supervisor_pid: manifest.pid,
|
|
195
|
+
status: manifest.status,
|
|
196
|
+
ipc: manifest.ipc,
|
|
197
|
+
createdAt: manifest.created_at,
|
|
198
|
+
};
|
|
199
|
+
if (manifest.exit_reason) info.exit_reason = manifest.exit_reason;
|
|
200
|
+
if (manifest.exit_code != null) info.exit_code = manifest.exit_code;
|
|
201
|
+
if (manifest.exit_signal) info.exit_signal = manifest.exit_signal;
|
|
202
|
+
return info;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Live PTY output stream for a supervisor-managed session.
|
|
207
|
+
*
|
|
208
|
+
* Returns an async generator yielding `{ data, seq }` per Frame::output (and
|
|
209
|
+
* a final `{ exit, ... }` on shutdown_drain). Consumer cancellation:
|
|
210
|
+
* `for await (...) { ...; break; }` or `signal.abort()` both unwind cleanly.
|
|
211
|
+
*
|
|
212
|
+
* Optional `fromSeq` triggers an A5 Resume frame so the supervisor replays
|
|
213
|
+
* log.jsonl entries with seq > fromSeq before live broadcast — useful when
|
|
214
|
+
* the caller reconnects after a brief disconnect.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} sessionId
|
|
217
|
+
* @param {{fromSeq?: ?number, signal?: AbortSignal|null, connectTimeoutMs?: number}} [options]
|
|
218
|
+
*/
|
|
219
|
+
async function* output(sessionId, options = {}) {
|
|
220
|
+
const manifest = findSupervisorManifest(sessionId);
|
|
221
|
+
if (!manifest) {
|
|
222
|
+
const err = new Error(`no supervisor manifest for session '${sessionId}'`);
|
|
223
|
+
err.code = 'ERR_NO_SUPERVISOR';
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
const { fromSeq = null, signal = null, connectTimeoutMs = 1500 } = options;
|
|
227
|
+
const client = await connect(manifest.ipc.path, { connectTimeoutMs });
|
|
228
|
+
try {
|
|
229
|
+
if (fromSeq != null) {
|
|
230
|
+
await client.send({ kind: 'resume', sid: sessionId, from_seq: fromSeq });
|
|
231
|
+
}
|
|
232
|
+
for await (const frame of client.subscribe({ sid: sessionId, signal })) {
|
|
233
|
+
if (frame.kind === 'output' && typeof frame.data === 'string') {
|
|
234
|
+
yield { data: frame.data, seq: typeof frame.seq === 'number' ? frame.seq : null };
|
|
235
|
+
} else if (frame.kind === 'shutdown_drain') {
|
|
236
|
+
yield {
|
|
237
|
+
data: '',
|
|
238
|
+
seq: null,
|
|
239
|
+
exit: {
|
|
240
|
+
reason: frame.exit_reason || null,
|
|
241
|
+
code: typeof frame.exit_code === 'number' ? frame.exit_code : null,
|
|
242
|
+
escalated: frame.escalated === true,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
try { await client.close(); } catch {}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
inject,
|
|
255
|
+
output,
|
|
256
|
+
list,
|
|
257
|
+
findSupervisorManifest,
|
|
258
|
+
readManifest,
|
|
259
|
+
toSessionInfo,
|
|
260
|
+
sessionDir,
|
|
261
|
+
manifestPath,
|
|
262
|
+
socketPath,
|
|
263
|
+
sessionsRoot,
|
|
264
|
+
};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Node↔Rust supervisor bridge — NDJSON over a per-session UDS.
|
|
4
|
+
//
|
|
5
|
+
// Wire schema mirror: crates/telepty-supervisor-core/src/wire.rs (v=1).
|
|
6
|
+
// Per synthesis ADR §6.2 (B3): inject/output/signal/kill/delete frames MUST
|
|
7
|
+
// carry a non-empty trace_id; the supervisor rejects with ERR_BAD_FRAME
|
|
8
|
+
// otherwise. This client auto-fills trace_id for those kinds if the caller
|
|
9
|
+
// omits it. Pong reflects trace_id back so request() correlates by trace_id.
|
|
10
|
+
//
|
|
11
|
+
// Stdlib only (Constitution §17). NDJSON parsing via readline.
|
|
12
|
+
|
|
13
|
+
const net = require('node:net');
|
|
14
|
+
const readline = require('node:readline');
|
|
15
|
+
const { randomUUID } = require('node:crypto');
|
|
16
|
+
|
|
17
|
+
const WIRE_VERSION = 1;
|
|
18
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 3000;
|
|
19
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 1500;
|
|
20
|
+
const KINDS_REQUIRING_TRACE_ID = new Set(['inject', 'output', 'signal', 'kill', 'delete']);
|
|
21
|
+
const CORRELATED_RESPONSE_KINDS = new Set(['pong', 'error']);
|
|
22
|
+
|
|
23
|
+
class BridgeClientError extends Error {
|
|
24
|
+
constructor(code, message, frame = null) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.frame = frame;
|
|
28
|
+
this.name = 'BridgeClientError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class BridgeClient {
|
|
33
|
+
constructor(socket) {
|
|
34
|
+
this._socket = socket;
|
|
35
|
+
this._pending = new Map();
|
|
36
|
+
this._subscribers = new Set();
|
|
37
|
+
this._closed = false;
|
|
38
|
+
this._closePromise = null;
|
|
39
|
+
|
|
40
|
+
const rl = readline.createInterface({ input: socket, crlfDelay: Infinity });
|
|
41
|
+
rl.on('line', (line) => this._handleLine(line));
|
|
42
|
+
socket.once('close', () => this._handleClose(null));
|
|
43
|
+
socket.once('error', (err) => this._handleError(err));
|
|
44
|
+
this._rl = rl;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns {boolean} */
|
|
48
|
+
isClosed() {
|
|
49
|
+
return this._closed;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Send a frame without awaiting a correlated reply. Resolves once the bytes
|
|
54
|
+
* are flushed to the OS write buffer. Auto-fills wire version and trace_id
|
|
55
|
+
* for kinds where the supervisor mandates trace_id (B3).
|
|
56
|
+
* @param {object} frame
|
|
57
|
+
* @returns {Promise<{trace_id: ?string}>}
|
|
58
|
+
*/
|
|
59
|
+
send(frame) {
|
|
60
|
+
if (this._closed) {
|
|
61
|
+
return Promise.reject(new BridgeClientError('ERR_SUPERVISOR_GONE', 'client closed'));
|
|
62
|
+
}
|
|
63
|
+
const out = this._prepareFrame(frame);
|
|
64
|
+
const line = JSON.stringify(out) + '\n';
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
this._socket.write(line, (err) => {
|
|
67
|
+
if (err) reject(new BridgeClientError('ERR_NOT_REACHABLE', err.message));
|
|
68
|
+
else resolve({ trace_id: out.trace_id || null });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Send a frame and await a correlated response (pong | error) carrying the
|
|
75
|
+
* same trace_id. Rejects with ERR_TIMEOUT on drift beyond `timeoutMs`. Error
|
|
76
|
+
* frames reject with the supervisor's ERR_* code preserved.
|
|
77
|
+
* @param {object} frame
|
|
78
|
+
* @param {{timeoutMs?: number}} [options]
|
|
79
|
+
* @returns {Promise<object>}
|
|
80
|
+
*/
|
|
81
|
+
request(frame, { timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
|
|
82
|
+
if (this._closed) {
|
|
83
|
+
return Promise.reject(new BridgeClientError('ERR_SUPERVISOR_GONE', 'client closed'));
|
|
84
|
+
}
|
|
85
|
+
const out = this._prepareFrame(frame);
|
|
86
|
+
if (!out.trace_id) {
|
|
87
|
+
out.trace_id = randomUUID();
|
|
88
|
+
}
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
if (this._pending.delete(out.trace_id)) {
|
|
92
|
+
reject(new BridgeClientError(
|
|
93
|
+
'ERR_TIMEOUT',
|
|
94
|
+
`no response within ${timeoutMs}ms (trace_id=${out.trace_id})`,
|
|
95
|
+
));
|
|
96
|
+
}
|
|
97
|
+
}, timeoutMs);
|
|
98
|
+
this._pending.set(out.trace_id, { resolve, reject, timer });
|
|
99
|
+
const line = JSON.stringify(out) + '\n';
|
|
100
|
+
this._socket.write(line, (err) => {
|
|
101
|
+
if (err && this._pending.delete(out.trace_id)) {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
reject(new BridgeClientError('ERR_NOT_REACHABLE', err.message));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Subscribe to incoming frames. Returns an AsyncIterator yielding every
|
|
111
|
+
* frame the supervisor emits (output broadcast, shutdown_drain, error,
|
|
112
|
+
* pong) optionally filtered by `sid`. Frames lacking a sid (error, pong)
|
|
113
|
+
* always pass through so callers can observe rejections.
|
|
114
|
+
*
|
|
115
|
+
* Cancellation: pass an AbortSignal, call `iterator.return()`, or `break`
|
|
116
|
+
* the for-await loop — all cleanly remove the subscription.
|
|
117
|
+
*
|
|
118
|
+
* @param {{sid?: string|null, signal?: AbortSignal|null}} [options]
|
|
119
|
+
*/
|
|
120
|
+
subscribe({ sid = null, signal = null } = {}) {
|
|
121
|
+
const queue = [];
|
|
122
|
+
const waiters = [];
|
|
123
|
+
let ended = false;
|
|
124
|
+
|
|
125
|
+
const sub = {
|
|
126
|
+
push: (frame) => {
|
|
127
|
+
if (ended) return;
|
|
128
|
+
if (sid && frame.sid != null && frame.sid !== sid) return;
|
|
129
|
+
const waiter = waiters.shift();
|
|
130
|
+
if (waiter) {
|
|
131
|
+
waiter({ value: frame, done: false });
|
|
132
|
+
} else {
|
|
133
|
+
queue.push(frame);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
end: () => {
|
|
137
|
+
if (ended) return;
|
|
138
|
+
ended = true;
|
|
139
|
+
while (waiters.length > 0) {
|
|
140
|
+
waiters.shift()({ value: undefined, done: true });
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
this._subscribers.add(sub);
|
|
146
|
+
|
|
147
|
+
const cleanup = () => {
|
|
148
|
+
this._subscribers.delete(sub);
|
|
149
|
+
sub.end();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (signal) {
|
|
153
|
+
if (signal.aborted) {
|
|
154
|
+
cleanup();
|
|
155
|
+
} else {
|
|
156
|
+
const onAbort = () => cleanup();
|
|
157
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const iter = {
|
|
162
|
+
[Symbol.asyncIterator]() {
|
|
163
|
+
return this;
|
|
164
|
+
},
|
|
165
|
+
next() {
|
|
166
|
+
if (queue.length > 0) {
|
|
167
|
+
return Promise.resolve({ value: queue.shift(), done: false });
|
|
168
|
+
}
|
|
169
|
+
if (ended) {
|
|
170
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
171
|
+
}
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
waiters.push(resolve);
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
return() {
|
|
177
|
+
cleanup();
|
|
178
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
179
|
+
},
|
|
180
|
+
throw(err) {
|
|
181
|
+
cleanup();
|
|
182
|
+
return Promise.reject(err);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
return iter;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Close the socket and reject any pending requests with ERR_SUPERVISOR_GONE.
|
|
190
|
+
* Idempotent.
|
|
191
|
+
* @returns {Promise<void>}
|
|
192
|
+
*/
|
|
193
|
+
close() {
|
|
194
|
+
if (this._closePromise) return this._closePromise;
|
|
195
|
+
this._closePromise = new Promise((resolve) => {
|
|
196
|
+
if (this._closed) {
|
|
197
|
+
resolve();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const finalize = () => {
|
|
201
|
+
resolve();
|
|
202
|
+
};
|
|
203
|
+
this._socket.once('close', finalize);
|
|
204
|
+
try {
|
|
205
|
+
this._socket.end();
|
|
206
|
+
} catch {
|
|
207
|
+
// already destroyed
|
|
208
|
+
}
|
|
209
|
+
const grace = setTimeout(() => {
|
|
210
|
+
if (!this._closed) {
|
|
211
|
+
try { this._socket.destroy(); } catch {}
|
|
212
|
+
}
|
|
213
|
+
}, 200);
|
|
214
|
+
grace.unref();
|
|
215
|
+
});
|
|
216
|
+
return this._closePromise;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_handleLine(line) {
|
|
220
|
+
if (!line) return;
|
|
221
|
+
let frame;
|
|
222
|
+
try {
|
|
223
|
+
frame = JSON.parse(line);
|
|
224
|
+
} catch {
|
|
225
|
+
// Protocol violation on incoming line — surface as a synthetic error to
|
|
226
|
+
// subscribers; do not tear down the connection (supervisor may still
|
|
227
|
+
// emit valid frames after malformed garbage in a misbehaving client).
|
|
228
|
+
const err = {
|
|
229
|
+
v: WIRE_VERSION,
|
|
230
|
+
kind: 'error',
|
|
231
|
+
code: 'ERR_BAD_FRAME',
|
|
232
|
+
data: 'client_parse_error',
|
|
233
|
+
};
|
|
234
|
+
for (const sub of this._subscribers) sub.push(err);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!frame || typeof frame !== 'object' || typeof frame.kind !== 'string') return;
|
|
238
|
+
|
|
239
|
+
if (frame.trace_id && CORRELATED_RESPONSE_KINDS.has(frame.kind) && this._pending.has(frame.trace_id)) {
|
|
240
|
+
const pending = this._pending.get(frame.trace_id);
|
|
241
|
+
this._pending.delete(frame.trace_id);
|
|
242
|
+
clearTimeout(pending.timer);
|
|
243
|
+
if (frame.kind === 'error') {
|
|
244
|
+
pending.reject(new BridgeClientError(
|
|
245
|
+
frame.code || 'ERR_BAD_FRAME',
|
|
246
|
+
frame.data || 'supervisor error',
|
|
247
|
+
frame,
|
|
248
|
+
));
|
|
249
|
+
} else {
|
|
250
|
+
pending.resolve(frame);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const sub of this._subscribers) sub.push(frame);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_handleClose(_err) {
|
|
258
|
+
if (this._closed) return;
|
|
259
|
+
this._closed = true;
|
|
260
|
+
const goneErr = new BridgeClientError('ERR_SUPERVISOR_GONE', 'supervisor socket closed');
|
|
261
|
+
for (const [, pending] of this._pending) {
|
|
262
|
+
clearTimeout(pending.timer);
|
|
263
|
+
pending.reject(goneErr);
|
|
264
|
+
}
|
|
265
|
+
this._pending.clear();
|
|
266
|
+
for (const sub of this._subscribers) sub.end();
|
|
267
|
+
this._subscribers.clear();
|
|
268
|
+
this._rl.close();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_handleError(_err) {
|
|
272
|
+
if (!this._closed) {
|
|
273
|
+
try { this._socket.destroy(); } catch {}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_prepareFrame(frame) {
|
|
278
|
+
const out = { v: WIRE_VERSION, ...frame };
|
|
279
|
+
if (KINDS_REQUIRING_TRACE_ID.has(out.kind) && !out.trace_id) {
|
|
280
|
+
out.trace_id = randomUUID();
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Connect to the per-session supervisor UDS socket.
|
|
288
|
+
* @param {string} socketPath
|
|
289
|
+
* @param {{connectTimeoutMs?: number}} [options]
|
|
290
|
+
* @returns {Promise<BridgeClient>}
|
|
291
|
+
*/
|
|
292
|
+
function connect(socketPath, { connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS } = {}) {
|
|
293
|
+
return new Promise((resolve, reject) => {
|
|
294
|
+
if (typeof socketPath !== 'string' || socketPath.length === 0) {
|
|
295
|
+
reject(new BridgeClientError('ERR_BAD_FRAME', 'socketPath required'));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const socket = net.createConnection({ path: socketPath });
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
socket.destroy();
|
|
301
|
+
reject(new BridgeClientError(
|
|
302
|
+
'ERR_NOT_REACHABLE',
|
|
303
|
+
`connect timeout ${connectTimeoutMs}ms (${socketPath})`,
|
|
304
|
+
));
|
|
305
|
+
}, connectTimeoutMs);
|
|
306
|
+
|
|
307
|
+
const onConnect = () => {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
socket.removeListener('error', onError);
|
|
310
|
+
resolve(new BridgeClient(socket));
|
|
311
|
+
};
|
|
312
|
+
const onError = (err) => {
|
|
313
|
+
clearTimeout(timer);
|
|
314
|
+
socket.removeListener('connect', onConnect);
|
|
315
|
+
try { socket.destroy(); } catch {}
|
|
316
|
+
reject(new BridgeClientError('ERR_NOT_REACHABLE', err.message));
|
|
317
|
+
};
|
|
318
|
+
socket.once('connect', onConnect);
|
|
319
|
+
socket.once('error', onError);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = {
|
|
324
|
+
connect,
|
|
325
|
+
BridgeClient,
|
|
326
|
+
BridgeClientError,
|
|
327
|
+
WIRE_VERSION,
|
|
328
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
329
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
330
|
+
};
|