@celilo/core 0.14.0 → 0.16.0
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/package.json +1 -1
- package/src/command-registry.ts +4 -0
- package/src/remote-client.ts +145 -10
- package/src/remote-session.test.ts +131 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Lightweight shared core for Celilo CLI tools — command registry, NDJSON API protocol, and remote SSH client. No Ink/React/drizzle/aws-sdk transitive deps, so an MCP server can reach the transport without installing the full CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/command-registry.ts
CHANGED
package/src/remote-client.ts
CHANGED
|
@@ -240,16 +240,19 @@ interface StreamOptions {
|
|
|
240
240
|
onBlocked: 'return' | 'wait';
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
243
|
+
/**
|
|
244
|
+
* The server's NDJSON stream, split into lines, as a generator that OUTLIVES a
|
|
245
|
+
* single command.
|
|
246
|
+
*
|
|
247
|
+
* `api-serve` is a persistent server: it loops reading commands until stdin
|
|
248
|
+
* closes, and its own protocol has always allowed many commands per connection.
|
|
249
|
+
* Splitting inside `consumeStream` tied the reader's lifetime to one command,
|
|
250
|
+
* so the only way to run a second was to open a second connection and re-pay
|
|
251
|
+
* the server's start-up. Hoisting the split is what lets a session reuse one.
|
|
252
|
+
*/
|
|
253
|
+
async function* lineStream(transport: RemoteTransport): AsyncGenerator<string> {
|
|
249
254
|
const decoder = new TextDecoder();
|
|
250
|
-
const errors: string[] = [];
|
|
251
255
|
let buffer = '';
|
|
252
|
-
|
|
253
256
|
for await (const chunk of transport.stdout) {
|
|
254
257
|
buffer += decoder.decode(chunk, { stream: true });
|
|
255
258
|
let nl = buffer.indexOf('\n');
|
|
@@ -257,7 +260,32 @@ async function consumeStream(
|
|
|
257
260
|
const line = buffer.slice(0, nl);
|
|
258
261
|
buffer = buffer.slice(nl + 1);
|
|
259
262
|
nl = buffer.indexOf('\n');
|
|
260
|
-
if (
|
|
263
|
+
if (line.trim()) yield line;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read the server's NDJSON stream until it goes terminal.
|
|
270
|
+
*
|
|
271
|
+
* Pulls from `lines` with an explicit `next()` rather than `for await`, because
|
|
272
|
+
* `for await` calls the iterator's `return()` when this function returns — which
|
|
273
|
+
* would close the generator and make the connection single-use again. That is
|
|
274
|
+
* the whole mechanism of connection reuse, in one loop construct.
|
|
275
|
+
*/
|
|
276
|
+
async function consumeStream(
|
|
277
|
+
transport: RemoteTransport,
|
|
278
|
+
lines: AsyncGenerator<string>,
|
|
279
|
+
opts: StreamOptions,
|
|
280
|
+
): Promise<RemoteOutcome | null> {
|
|
281
|
+
const { out, display, renderInterview } = opts;
|
|
282
|
+
const errors: string[] = [];
|
|
283
|
+
|
|
284
|
+
while (true) {
|
|
285
|
+
const next = await lines.next();
|
|
286
|
+
if (next.done === true) break;
|
|
287
|
+
{
|
|
288
|
+
const line = next.value;
|
|
261
289
|
|
|
262
290
|
let msg: ServerMessage;
|
|
263
291
|
try {
|
|
@@ -340,6 +368,113 @@ export async function runRemoteClient(
|
|
|
340
368
|
return openAndConsume(dest, { type: 'command', argv }, opts);
|
|
341
369
|
}
|
|
342
370
|
|
|
371
|
+
/**
|
|
372
|
+
* One connection, many commands.
|
|
373
|
+
*
|
|
374
|
+
* `runRemoteClient` opens a transport, sends ONE command and kills it. That is
|
|
375
|
+
* right for a CLI invocation and wrong for a poller: `api-serve` is a
|
|
376
|
+
* persistent server whose read loop accepts commands until stdin closes, so a
|
|
377
|
+
* caller issuing a command every few seconds re-paid its start-up every time.
|
|
378
|
+
*
|
|
379
|
+
* Measured on celilo-mgr, 2026-09-28, three commands on ONE connection:
|
|
380
|
+
*
|
|
381
|
+
* ready 1.80s
|
|
382
|
+
* result 1 (alerts list) 3.44s -> 1.64s of work after start-up
|
|
383
|
+
* result 2 (alerts list) 7.25s -> 1.25s (sent at 6s)
|
|
384
|
+
* result 3 (console status) 12.06s -> 0.06s (sent at 12s)
|
|
385
|
+
*
|
|
386
|
+
* The ~1.8s is paid ONCE. The web console was paying it on every panel read,
|
|
387
|
+
* which is most of why a panel cost 5-11s against a 15s client timeout.
|
|
388
|
+
*
|
|
389
|
+
* Commands are SERIALIZED, because the server runs one at a time — `serve.ts`
|
|
390
|
+
* holds a single `commandRunning` slot. Issuing two concurrently would
|
|
391
|
+
* interleave their output on one stream with nothing to correlate it by, so the
|
|
392
|
+
* queue here is not a policy choice, it mirrors the server.
|
|
393
|
+
*
|
|
394
|
+
* This does NOT reintroduce celilo#921. That was 656 ORPHANED ssh clients
|
|
395
|
+
* accumulating because nothing closed them; a session is one connection with an
|
|
396
|
+
* explicit `close()`, and the transport registry still kills it on exit.
|
|
397
|
+
*/
|
|
398
|
+
export interface RemoteSession {
|
|
399
|
+
/**
|
|
400
|
+
* Run one command and resolve on its terminal message. Queued behind any
|
|
401
|
+
* command already in flight.
|
|
402
|
+
*
|
|
403
|
+
* Rejects if the session is closed, rather than silently opening a second
|
|
404
|
+
* connection — a caller that wants a new one should say so.
|
|
405
|
+
*/
|
|
406
|
+
run(argv: readonly string[], opts?: { out?: DisplayWriter }): Promise<RemoteOutcome>;
|
|
407
|
+
/** Tear the connection down. Idempotent. */
|
|
408
|
+
close(): void;
|
|
409
|
+
/** True once `close()` ran, or the server's stream ended. */
|
|
410
|
+
readonly closed: boolean;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function openRemoteSession(dest: string, opts: RemoteClientOptions = {}): RemoteSession {
|
|
414
|
+
const openTransport = opts.openTransport ?? openSshTransport;
|
|
415
|
+
const transport = openTransport(dest);
|
|
416
|
+
const lines = lineStream(transport);
|
|
417
|
+
|
|
418
|
+
let closed = false;
|
|
419
|
+
// Commands run one at a time. The chain is the queue: each run appends to it,
|
|
420
|
+
// so a caller never has to know whether another is in flight.
|
|
421
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
422
|
+
|
|
423
|
+
const close = (): void => {
|
|
424
|
+
if (closed) return;
|
|
425
|
+
closed = true;
|
|
426
|
+
try {
|
|
427
|
+
transport.kill();
|
|
428
|
+
} catch {
|
|
429
|
+
// Already gone. Closing twice must not throw at a caller trying to tidy up.
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const runOne = async (
|
|
434
|
+
argv: readonly string[],
|
|
435
|
+
runOpts: { out?: DisplayWriter } = {},
|
|
436
|
+
): Promise<RemoteOutcome> => {
|
|
437
|
+
if (closed) throw new Error('remote session is closed');
|
|
438
|
+
const out = runOpts.out ?? opts.out ?? process.stdout;
|
|
439
|
+
|
|
440
|
+
transport.stdin.write(`${JSON.stringify({ type: 'command', argv: [...argv] })}\n`);
|
|
441
|
+
await transport.stdin.flush?.();
|
|
442
|
+
|
|
443
|
+
const outcome = await consumeStream(transport, lines, {
|
|
444
|
+
out,
|
|
445
|
+
display: new ProgressDisplay({ out }),
|
|
446
|
+
renderInterview: opts.renderInterview ?? defaultRenderInterview,
|
|
447
|
+
// A session is headless by construction: it is reused precisely because
|
|
448
|
+
// nobody is sitting at it. Waiting on a question would wedge every later
|
|
449
|
+
// command behind one nobody is going to answer.
|
|
450
|
+
onBlocked: opts.onBlocked ?? 'return',
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
if (outcome === null) {
|
|
454
|
+
// The stream ended mid-command: the server went away. Mark the session
|
|
455
|
+
// dead so the next caller opens a fresh one instead of writing into a
|
|
456
|
+
// pipe nobody reads.
|
|
457
|
+
close();
|
|
458
|
+
return { status: 'result', exitCode: await transport.exited };
|
|
459
|
+
}
|
|
460
|
+
return outcome;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
run(argv, runOpts) {
|
|
465
|
+
const result = tail.then(() => runOne(argv, runOpts));
|
|
466
|
+
// The queue must survive a failed command, or one rejection strands every
|
|
467
|
+
// command behind it forever.
|
|
468
|
+
tail = result.catch(() => undefined);
|
|
469
|
+
return result;
|
|
470
|
+
},
|
|
471
|
+
close,
|
|
472
|
+
get closed() {
|
|
473
|
+
return closed;
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
343
478
|
/**
|
|
344
479
|
* Re-join a parked session: replays the output produced while nobody was
|
|
345
480
|
* attached, then streams the rest through to the terminal `result`.
|
|
@@ -377,7 +512,7 @@ async function openAndConsume(
|
|
|
377
512
|
transport.stdin.write(`${JSON.stringify(opening)}\n`);
|
|
378
513
|
await transport.stdin.flush?.();
|
|
379
514
|
|
|
380
|
-
const outcome = await consumeStream(transport, {
|
|
515
|
+
const outcome = await consumeStream(transport, lineStream(transport), {
|
|
381
516
|
out,
|
|
382
517
|
display: new ProgressDisplay({ out }),
|
|
383
518
|
renderInterview: opts.renderInterview ?? defaultRenderInterview,
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One connection, many commands.
|
|
3
|
+
*
|
|
4
|
+
* `runRemoteClient` opens a transport, sends one command and kills it. For a
|
|
5
|
+
* poller that re-pays the server's start-up every time: measured on celilo-mgr,
|
|
6
|
+
* `ready` at 1.80s then commands at 1.64s / 1.25s / 0.06s on the SAME
|
|
7
|
+
* connection. The web console was paying the 1.8s per panel read.
|
|
8
|
+
*
|
|
9
|
+
* WATCHED RED: switching `consumeStream`'s manual `lines.next()` back to
|
|
10
|
+
* `for await (const line of lines)` turns 'a second command reuses the
|
|
11
|
+
* connection' red — `for await` calls the generator's `return()` when the
|
|
12
|
+
* function returns, closing the reader and making the connection single-use.
|
|
13
|
+
* That one loop construct IS the mechanism.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { API_PROTOCOL_VERSION, type RemoteTransport, openRemoteSession } from './index';
|
|
17
|
+
|
|
18
|
+
function ndjson(...messages: unknown[]): Uint8Array {
|
|
19
|
+
return new TextEncoder().encode(`${messages.map((m) => JSON.stringify(m)).join('\n')}\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A server that answers each command it is sent, on one long-lived stream. */
|
|
23
|
+
function fakeServer() {
|
|
24
|
+
const state = { opens: 0, sent: [] as string[], closed: false };
|
|
25
|
+
let push: ((b: Uint8Array) => void) | null = null;
|
|
26
|
+
let finish: (() => void) | null = null;
|
|
27
|
+
|
|
28
|
+
const open = (): RemoteTransport => {
|
|
29
|
+
state.opens += 1;
|
|
30
|
+
const stdout = new ReadableStream<Uint8Array>({
|
|
31
|
+
start(c) {
|
|
32
|
+
push = (b) => c.enqueue(b);
|
|
33
|
+
finish = () => {
|
|
34
|
+
try {
|
|
35
|
+
c.close();
|
|
36
|
+
} catch {
|
|
37
|
+
// already closed
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
c.enqueue(ndjson({ type: 'ready', protocolVersion: API_PROTOCOL_VERSION }));
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
stdin: {
|
|
45
|
+
write: (s: string) => {
|
|
46
|
+
state.sent.push(s.trim());
|
|
47
|
+
const argv = JSON.parse(s).argv as string[];
|
|
48
|
+
// Answer asynchronously, as a real server would.
|
|
49
|
+
queueMicrotask(() =>
|
|
50
|
+
push?.(
|
|
51
|
+
ndjson(
|
|
52
|
+
{ type: 'log', message: `ran ${argv.join(' ')}`, stream: 'stdout' },
|
|
53
|
+
{ type: 'result', success: true, exitCode: 0 },
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
end: () => {},
|
|
59
|
+
},
|
|
60
|
+
stdout,
|
|
61
|
+
kill: () => {
|
|
62
|
+
state.closed = true;
|
|
63
|
+
finish?.();
|
|
64
|
+
},
|
|
65
|
+
exited: Promise.resolve(0),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
return { state, open };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const collect = () => {
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
return { out: { write: (s: string) => lines.push(s), isTTY: false }, lines };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe('openRemoteSession', () => {
|
|
77
|
+
test('a second command reuses the connection instead of opening another', async () => {
|
|
78
|
+
const { state, open } = fakeServer();
|
|
79
|
+
const s = openRemoteSession('celilo-api@example', { openTransport: open });
|
|
80
|
+
|
|
81
|
+
const a = collect();
|
|
82
|
+
const b = collect();
|
|
83
|
+
expect(await s.run(['alerts', 'list'], { out: a.out })).toMatchObject({
|
|
84
|
+
status: 'result',
|
|
85
|
+
exitCode: 0,
|
|
86
|
+
});
|
|
87
|
+
expect(await s.run(['console', 'status'], { out: b.out })).toMatchObject({
|
|
88
|
+
status: 'result',
|
|
89
|
+
exitCode: 0,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(state.opens).toBe(1); // the whole point
|
|
93
|
+
expect(a.lines.join('')).toContain('ran alerts list');
|
|
94
|
+
expect(b.lines.join('')).toContain('ran console status');
|
|
95
|
+
s.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('commands are serialized, because the server runs one at a time', async () => {
|
|
99
|
+
const { state, open } = fakeServer();
|
|
100
|
+
const s = openRemoteSession('celilo-api@example', { openTransport: open });
|
|
101
|
+
|
|
102
|
+
// Issued together, without awaiting between them.
|
|
103
|
+
await Promise.all([s.run(['a']), s.run(['b']), s.run(['c'])]);
|
|
104
|
+
|
|
105
|
+
expect(state.opens).toBe(1);
|
|
106
|
+
// Order preserved, and each was written only after the previous finished.
|
|
107
|
+
expect(state.sent.map((l) => JSON.parse(l).argv[0])).toEqual(['a', 'b', 'c']);
|
|
108
|
+
s.close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a failed command does not strand the ones queued behind it', async () => {
|
|
112
|
+
// The queue is a promise chain; without catching, one rejection poisons the
|
|
113
|
+
// tail and every later command hangs on it forever.
|
|
114
|
+
const { open } = fakeServer();
|
|
115
|
+
const s = openRemoteSession('celilo-api@example', { openTransport: open });
|
|
116
|
+
s.close(); // force the next run to reject
|
|
117
|
+
await expect(s.run(['a'])).rejects.toThrow(/closed/);
|
|
118
|
+
await expect(s.run(['b'])).rejects.toThrow(/closed/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('close is idempotent and refuses further commands', async () => {
|
|
122
|
+
const { state, open } = fakeServer();
|
|
123
|
+
const s = openRemoteSession('celilo-api@example', { openTransport: open });
|
|
124
|
+
await s.run(['alerts', 'list']);
|
|
125
|
+
s.close();
|
|
126
|
+
s.close();
|
|
127
|
+
expect(state.closed).toBe(true);
|
|
128
|
+
expect(s.closed).toBe(true);
|
|
129
|
+
await expect(s.run(['again'])).rejects.toThrow(/closed/);
|
|
130
|
+
});
|
|
131
|
+
});
|