@celilo/core 0.4.0 → 0.6.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.4.0",
3
+ "version": "0.6.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",
@@ -168,6 +168,23 @@ export const COMMANDS: CommandDef[] = [
168
168
  { name: 'limit', description: 'Max rows to return', takesValue: true },
169
169
  ],
170
170
  },
171
+ {
172
+ name: 'list-failed',
173
+ description: 'List failed/abandoned deliveries with a true total',
174
+ flags: [
175
+ {
176
+ name: 'subscriber',
177
+ description: 'Restrict to a subscriber by name',
178
+ takesValue: true,
179
+ },
180
+ { name: 'limit', description: 'Max rows to return', takesValue: true },
181
+ ],
182
+ },
183
+ {
184
+ name: 'list-unanswered',
185
+ description: 'List interview questions nobody has answered yet',
186
+ flags: [{ name: 'limit', description: 'Max rows to return', takesValue: true }],
187
+ },
171
188
  {
172
189
  name: 'drain',
173
190
  description: 'Process pending deliveries once and return',
package/src/protocol.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * A versioned, Zod-validated tagged union carried as NDJSON. This is the
5
5
  * transport-neutral message layer for driving the CLI over the wire — see
6
- * `openspec/changes/replace-ssh-cli-api/proposal.md`. Slice 1 covers non-interactive command execution
7
- * + streamed output; `interview` / `answer` / `cancel` arrive in Slice 3.
6
+ * `openspec/changes/replace-ssh-cli-api/proposal.md` and
7
+ * `openspec/changes/park-unanswerable-interviews/proposal.md`.
8
8
  *
9
9
  * The command crosses the wire as a *structured, validated* `argv` array —
10
10
  * never a shell string handed to `exec`.
@@ -23,26 +23,53 @@ export const CommandMessageSchema = z.object({
23
23
  });
24
24
  export type CommandMessage = z.infer<typeof CommandMessageSchema>;
25
25
 
26
- /**
27
- * Client's answer to a server `interview` message, correlated by `id`.
28
- *
29
- * `error` is the "I cannot answer this" reply: the client had no way to reach a
30
- * decider (no TTY, no pre-staged answer). The server fails the waiting command
31
- * with that message instead of it resolving to the question's `defaultValue` —
32
- * an unanswered question must never become a silent "no".
33
- */
26
+ /** Client's answer to a server `interview` message, correlated by `id`. */
34
27
  export const AnswerMessageSchema = z.object({
35
28
  type: z.literal('answer'),
36
29
  id: z.string(),
37
30
  value: z.unknown(),
38
- error: z.string().optional(),
39
31
  });
40
32
  export type AnswerMessage = z.infer<typeof AnswerMessageSchema>;
41
33
 
34
+ /**
35
+ * "I cannot decide this" — the client had no way to reach a decider (no TTY, no
36
+ * pre-staged answer) for the `interview` with this `id`.
37
+ *
38
+ * It is deliberately NOT an `answer`: the server must not resolve the question,
39
+ * because a resolution of any shape consumes it and nobody else can then answer.
40
+ * The server parks the command and replies `blocked` (celilo#609).
41
+ */
42
+ export const UnanswerableMessageSchema = z.object({
43
+ type: z.literal('unanswerable'),
44
+ id: z.string(),
45
+ reason: z.string(),
46
+ });
47
+ export type UnanswerableMessage = z.infer<typeof UnanswerableMessageSchema>;
48
+
49
+ /** Re-join a parked session's output stream. Authorized against its owner. */
50
+ export const AttachMessageSchema = z.object({
51
+ type: z.literal('attach'),
52
+ sessionId: z.string(),
53
+ });
54
+ export type AttachMessage = z.infer<typeof AttachMessageSchema>;
55
+
56
+ /**
57
+ * Deliberately abandon a parked session before its TTL expires: the outstanding
58
+ * query is answered `abandoned`, the command fails naming what was never
59
+ * decided, and the record is retained.
60
+ */
61
+ export const CancelMessageSchema = z.object({
62
+ type: z.literal('cancel'),
63
+ sessionId: z.string(),
64
+ });
65
+ export type CancelMessage = z.infer<typeof CancelMessageSchema>;
66
+
42
67
  export const ClientMessageSchema = z.discriminatedUnion('type', [
43
68
  CommandMessageSchema,
44
69
  AnswerMessageSchema,
45
- // Slice 3+: CancelMessageSchema
70
+ UnanswerableMessageSchema,
71
+ AttachMessageSchema,
72
+ CancelMessageSchema,
46
73
  ]);
47
74
  export type ClientMessage = z.infer<typeof ClientMessageSchema>;
48
75
 
@@ -119,6 +146,23 @@ export const InterviewMessageSchema = z.object({
119
146
  });
120
147
  export type InterviewMessage = z.infer<typeof InterviewMessageSchema>;
121
148
 
149
+ /**
150
+ * Terminal-for-now: the command is parked on a question this client cannot
151
+ * decide. The command is still alive server-side under `sessionId`; answer
152
+ * `eventId` (`celilo events reply <eventId> <value>`) and `attach` to collect
153
+ * the outcome. Not a failure and not a decline — nothing was decided yet.
154
+ */
155
+ export const BlockedMessageSchema = z.object({
156
+ type: z.literal('blocked'),
157
+ sessionId: z.string(),
158
+ /** Bus event id of the unanswered query, as a string. */
159
+ eventId: z.string(),
160
+ question: z.string(),
161
+ /** The question's `<scope>.<key>`, when the server sent one. */
162
+ key: z.string().optional(),
163
+ });
164
+ export type BlockedMessage = z.infer<typeof BlockedMessageSchema>;
165
+
122
166
  export const ServerMessageSchema = z.discriminatedUnion('type', [
123
167
  ReadyMessageSchema,
124
168
  ProgressMessageSchema,
@@ -126,6 +170,7 @@ export const ServerMessageSchema = z.discriminatedUnion('type', [
126
170
  ResultMessageSchema,
127
171
  ErrorMessageSchema,
128
172
  InterviewMessageSchema,
173
+ BlockedMessageSchema,
129
174
  ]);
130
175
  export type ServerMessage = z.infer<typeof ServerMessageSchema>;
131
176
 
@@ -12,7 +12,12 @@ import { spawn } from 'node:child_process';
12
12
  import { Readable } from 'node:stream';
13
13
  import { type DisplayWriter, ProgressDisplay } from '@celilo/cli-display';
14
14
  import * as clack from '@clack/prompts';
15
- import { type InterviewMessage, type ServerMessage, ServerMessageSchema } from './protocol';
15
+ import {
16
+ type ClientMessage,
17
+ type InterviewMessage,
18
+ type ServerMessage,
19
+ ServerMessageSchema,
20
+ } from './protocol';
16
21
 
17
22
  const REMOTE_FLAG = '--remote';
18
23
 
@@ -23,8 +28,9 @@ export function interviewKey(iv: InterviewMessage): string | null {
23
28
 
24
29
  /**
25
30
  * Thrown by a renderer that has no way to reach a decider. `runRemoteClient`
26
- * turns it into an `answer` carrying `error`, so the server fails the waiting
27
- * command loudly rather than the question resolving to its default.
31
+ * turns it into an `unanswerable` message — never an `answer` — so the server
32
+ * parks the command with the question still standing rather than resolving it
33
+ * (to a default, or to anything else) on nobody's authority.
28
34
  */
29
35
  export class InterviewUnanswerableError extends Error {
30
36
  constructor(message: string) {
@@ -86,7 +92,15 @@ async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
86
92
 
87
93
  /** Minimal transport surface — satisfied by a `node:child_process` spawn of `ssh`. */
88
94
  export interface RemoteTransport {
89
- stdin: { write(chunk: string): void; flush?(): number | Promise<number> };
95
+ stdin: {
96
+ write(chunk: string): void;
97
+ flush?(): number | Promise<number>;
98
+ /**
99
+ * Close our end without tearing the server down. Used when a command parks:
100
+ * killing the transport kills the session we specifically want to survive.
101
+ */
102
+ end?(): void;
103
+ };
90
104
  stdout: ReadableStream<Uint8Array>;
91
105
  kill(): void;
92
106
  exited: Promise<number>;
@@ -121,7 +135,10 @@ function openSshTransport(dest: string): RemoteTransport {
121
135
  const proc = spawn('ssh', ['-T', dest], { stdio: ['pipe', 'pipe', 'inherit'] });
122
136
  if (!proc.stdout || !proc.stdin) throw new Error('ssh was not spawned with piped stdio');
123
137
  return {
124
- stdin: { write: (chunk: string) => void proc.stdin?.write(chunk) },
138
+ stdin: {
139
+ write: (chunk: string) => void proc.stdin?.write(chunk),
140
+ end: () => void proc.stdin?.end(),
141
+ },
125
142
  stdout: Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
126
143
  kill: () => void proc.kill(),
127
144
  exited: new Promise<number>((resolve) => proc.once('exit', (code) => resolve(code ?? 0))),
@@ -171,6 +188,7 @@ function applyMessage(
171
188
  process.stderr.write(`${msg.error}\n`);
172
189
  return null;
173
190
  case 'interview':
191
+ case 'blocked':
174
192
  // Handled by the caller (needs the transport to reply) — never reached here.
175
193
  return null;
176
194
  case 'result':
@@ -178,36 +196,40 @@ function applyMessage(
178
196
  }
179
197
  }
180
198
 
181
- export async function runRemoteClient(
182
- dest: string,
183
- argv: string[],
184
- opts: {
185
- openTransport?: (dest: string) => RemoteTransport;
186
- out?: DisplayWriter;
187
- renderInterview?: (interview: InterviewMessage) => Promise<unknown>;
188
- } = {},
189
- ): Promise<number> {
190
- if (argv.length === 0) {
191
- process.stderr.write(
192
- 'celilo --remote <dest> requires a command (e.g. celilo --remote host module list)\n',
193
- );
194
- return 2;
195
- }
199
+ /**
200
+ * How a remote run ended. `blocked` is not a failure and not a decline: the
201
+ * command is parked server-side on a question this client could not decide, and
202
+ * is still alive under `sessionId`.
203
+ */
204
+ export type RemoteOutcome =
205
+ | { status: 'result'; exitCode: number }
206
+ | { status: 'blocked'; sessionId: string; eventId: string; question: string; key?: string };
196
207
 
197
- const openTransport = opts.openTransport ?? openSshTransport;
198
- const out = opts.out ?? process.stdout;
199
- const renderInterview = opts.renderInterview ?? defaultRenderInterview;
200
- const display = new ProgressDisplay({ out });
208
+ /** Exit code a caller reports when a command parked rather than finishing. */
209
+ export const EXIT_BLOCKED = 75; // EX_TEMPFAIL — try again once it's answered.
201
210
 
202
- const transport = openTransport(dest);
203
- transport.stdin.write(`${JSON.stringify({ type: 'command', argv })}\n`);
204
- await transport.stdin.flush?.();
211
+ interface StreamOptions {
212
+ out: DisplayWriter;
213
+ display: ProgressDisplay;
214
+ renderInterview: (interview: InterviewMessage) => Promise<unknown>;
215
+ /**
216
+ * What to do with a `blocked` message. A caller that *is* a decider (an
217
+ * operator at a terminal) keeps waiting; a headless one returns so it can
218
+ * answer out-of-band and attach later.
219
+ */
220
+ onBlocked: 'return' | 'wait';
221
+ }
205
222
 
206
- let resultExit: number | null = null;
223
+ /** Read the server's NDJSON stream until it goes terminal. */
224
+ async function consumeStream(
225
+ transport: RemoteTransport,
226
+ opts: StreamOptions,
227
+ ): Promise<RemoteOutcome | null> {
228
+ const { out, display, renderInterview } = opts;
207
229
  const decoder = new TextDecoder();
208
230
  let buffer = '';
209
231
 
210
- outer: for await (const chunk of transport.stdout) {
232
+ for await (const chunk of transport.stdout) {
211
233
  buffer += decoder.decode(chunk, { stream: true });
212
234
  let nl = buffer.indexOf('\n');
213
235
  while (nl >= 0) {
@@ -226,38 +248,126 @@ export async function runRemoteClient(
226
248
  }
227
249
 
228
250
  if (msg.type === 'interview') {
229
- let answer: { type: 'answer'; id: string; value: unknown; error?: string };
251
+ let reply: string;
230
252
  try {
231
- answer = { type: 'answer', id: msg.id, value: await renderInterview(msg) };
253
+ const value = await renderInterview(msg);
254
+ reply = JSON.stringify({ type: 'answer', id: msg.id, value });
232
255
  } catch (err) {
233
- // Never fabricate an answer: tell the server we can't decide, and let
234
- // it fail the waiting command with this message.
235
- answer = {
236
- type: 'answer',
256
+ // Never fabricate an answer, and never resolve the question at all:
257
+ // say we can't decide and let the server park it.
258
+ reply = JSON.stringify({
259
+ type: 'unanswerable',
237
260
  id: msg.id,
238
- value: null,
239
- error: err instanceof Error ? err.message : String(err),
240
- };
261
+ reason: err instanceof Error ? err.message : String(err),
262
+ });
241
263
  }
242
- transport.stdin.write(`${JSON.stringify(answer)}\n`);
264
+ transport.stdin.write(`${reply}\n`);
243
265
  await transport.stdin.flush?.();
244
266
  continue;
245
267
  }
246
268
 
247
- const exit = applyMessage(msg, display, out);
248
- if (exit !== null) {
249
- resultExit = exit;
250
- break outer;
269
+ if (msg.type === 'blocked') {
270
+ if (opts.onBlocked === 'wait') {
271
+ out.write(
272
+ `Waiting on: ${msg.question} (answer with: celilo events reply ${msg.eventId} <value>)\n`,
273
+ );
274
+ continue;
275
+ }
276
+ return {
277
+ status: 'blocked',
278
+ sessionId: msg.sessionId,
279
+ eventId: msg.eventId,
280
+ question: msg.question,
281
+ key: msg.key,
282
+ };
251
283
  }
284
+
285
+ const exit = applyMessage(msg, display, out);
286
+ if (exit !== null) return { status: 'result', exitCode: exit };
252
287
  }
253
288
  }
289
+ return null;
290
+ }
291
+
292
+ export interface RemoteClientOptions {
293
+ openTransport?: (dest: string) => RemoteTransport;
294
+ out?: DisplayWriter;
295
+ renderInterview?: (interview: InterviewMessage) => Promise<unknown>;
296
+ onBlocked?: 'return' | 'wait';
297
+ }
254
298
 
255
- if (resultExit !== null) {
256
- // Server is still looping awaiting more input — tear it down.
257
- transport.kill();
258
- return resultExit;
299
+ export async function runRemoteClient(
300
+ dest: string,
301
+ argv: string[],
302
+ opts: RemoteClientOptions = {},
303
+ ): Promise<RemoteOutcome> {
304
+ if (argv.length === 0) {
305
+ process.stderr.write(
306
+ 'celilo --remote <dest> requires a command (e.g. celilo --remote host module list)\n',
307
+ );
308
+ return { status: 'result', exitCode: 2 };
309
+ }
310
+
311
+ return openAndConsume(dest, { type: 'command', argv }, opts);
312
+ }
313
+
314
+ /**
315
+ * Re-join a parked session: replays the output produced while nobody was
316
+ * attached, then streams the rest through to the terminal `result`.
317
+ */
318
+ export function attachRemoteClient(
319
+ dest: string,
320
+ sessionId: string,
321
+ opts: RemoteClientOptions = {},
322
+ ): Promise<RemoteOutcome> {
323
+ return openAndConsume(dest, { type: 'attach', sessionId }, opts);
324
+ }
325
+
326
+ /**
327
+ * Deliberately abandon a parked session: its outstanding question is answered
328
+ * `abandoned` and the command fails naming what was never decided. The
329
+ * operator-initiated counterpart to the TTL reaper.
330
+ */
331
+ export function cancelRemoteClient(
332
+ dest: string,
333
+ sessionId: string,
334
+ opts: RemoteClientOptions = {},
335
+ ): Promise<RemoteOutcome> {
336
+ return openAndConsume(dest, { type: 'cancel', sessionId }, opts);
337
+ }
338
+
339
+ /** Open a transport, send one opening message, and consume until terminal. */
340
+ async function openAndConsume(
341
+ dest: string,
342
+ opening: ClientMessage,
343
+ opts: RemoteClientOptions,
344
+ ): Promise<RemoteOutcome> {
345
+ const openTransport = opts.openTransport ?? openSshTransport;
346
+ const out = opts.out ?? process.stdout;
347
+ const transport = openTransport(dest);
348
+ transport.stdin.write(`${JSON.stringify(opening)}\n`);
349
+ await transport.stdin.flush?.();
350
+
351
+ const outcome = await consumeStream(transport, {
352
+ out,
353
+ display: new ProgressDisplay({ out }),
354
+ renderInterview: opts.renderInterview ?? defaultRenderInterview,
355
+ // A terminal operator is a decider, so they wait rather than being handed a
356
+ // session id to answer out-of-band.
357
+ onBlocked: opts.onBlocked ?? (process.stdin.isTTY ? 'wait' : 'return'),
358
+ });
359
+
360
+ if (outcome?.status === 'blocked') {
361
+ // Do NOT kill: the command is parked and must outlive us. Close our stdin
362
+ // and leave; the server keeps the child and buffers its output.
363
+ transport.stdin.end?.();
364
+ return outcome;
259
365
  }
260
366
 
261
- // Stream ended without a result — surface the transport's exit (ssh failure).
262
- return transport.exited;
367
+ // Server is still looping awaiting more input — tear our end down.
368
+ transport.kill();
369
+ if (outcome) return outcome;
370
+
371
+ // Stream ended without a terminal message — surface the transport's exit.
372
+ return { status: 'result', exitCode: await transport.exited };
263
373
  }