@celilo/core 0.15.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/core",
3
- "version": "0.15.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",
@@ -240,16 +240,19 @@ interface StreamOptions {
240
240
  onBlocked: 'return' | 'wait';
241
241
  }
242
242
 
243
- /** Read the server's NDJSON stream until it goes terminal. */
244
- async function consumeStream(
245
- transport: RemoteTransport,
246
- opts: StreamOptions,
247
- ): Promise<RemoteOutcome | null> {
248
- const { out, display, renderInterview } = opts;
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 (!line.trim()) continue;
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
+ });