@celilo/core 0.3.2 → 0.5.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.3.2",
3
+ "version": "0.5.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,11 @@ export const COMMANDS: CommandDef[] = [
168
168
  { name: 'limit', description: 'Max rows to return', takesValue: true },
169
169
  ],
170
170
  },
171
+ {
172
+ name: 'list-unanswered',
173
+ description: 'List interview questions nobody has answered yet',
174
+ flags: [{ name: 'limit', description: 'Max rows to return', takesValue: true }],
175
+ },
171
176
  {
172
177
  name: 'drain',
173
178
  description: 'Process pending deliveries once and return',
@@ -306,6 +311,12 @@ export const COMMANDS: CommandDef[] = [
306
311
  description: 'Remove the installed supervisor unit',
307
312
  flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
308
313
  },
314
+ {
315
+ name: 'restart-daemon',
316
+ description:
317
+ 'Restart the dispatcher through its supervisor, stopping any unsupervised one, and verify the new process reports the installed code version',
318
+ flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
319
+ },
309
320
  {
310
321
  name: 'show-daemon',
311
322
  description: 'Print the currently installed unit file',
@@ -1218,6 +1229,14 @@ export const COMMANDS: CommandDef[] = [
1218
1229
  {
1219
1230
  name: 'migrate',
1220
1231
  description: 'Apply pending database migrations (idempotent; safe to re-run)',
1232
+ flags: [
1233
+ {
1234
+ name: 'status',
1235
+ description:
1236
+ 'Report applied count, latest applied migration and pending ones by name without applying anything',
1237
+ takesValue: false,
1238
+ },
1239
+ ],
1221
1240
  },
1222
1241
  {
1223
1242
  name: 'doctor',
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`.
@@ -31,10 +31,45 @@ export const AnswerMessageSchema = z.object({
31
31
  });
32
32
  export type AnswerMessage = z.infer<typeof AnswerMessageSchema>;
33
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
+
34
67
  export const ClientMessageSchema = z.discriminatedUnion('type', [
35
68
  CommandMessageSchema,
36
69
  AnswerMessageSchema,
37
- // Slice 3+: CancelMessageSchema
70
+ UnanswerableMessageSchema,
71
+ AttachMessageSchema,
72
+ CancelMessageSchema,
38
73
  ]);
39
74
  export type ClientMessage = z.infer<typeof ClientMessageSchema>;
40
75
 
@@ -91,6 +126,14 @@ export type ErrorMessage = z.infer<typeof ErrorMessageSchema>;
91
126
  export const InterviewMessageSchema = z.object({
92
127
  type: z.literal('interview'),
93
128
  id: z.string(),
129
+ /**
130
+ * The question's stable identity (`interview.required.<scope>.<key>`). A
131
+ * client can pre-stage an answer under `<scope>.<key>` — the same lookup key
132
+ * `celilo events respond --values` uses. Optional so a client still parses
133
+ * messages from an older server that didn't send them.
134
+ */
135
+ scope: z.string().optional(),
136
+ key: z.string().optional(),
94
137
  kind: z.enum(['text', 'confirm', 'select', 'multiselect']),
95
138
  message: z.string(),
96
139
  description: z.string().optional(),
@@ -103,6 +146,23 @@ export const InterviewMessageSchema = z.object({
103
146
  });
104
147
  export type InterviewMessage = z.infer<typeof InterviewMessageSchema>;
105
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
+
106
166
  export const ServerMessageSchema = z.discriminatedUnion('type', [
107
167
  ReadyMessageSchema,
108
168
  ProgressMessageSchema,
@@ -110,6 +170,7 @@ export const ServerMessageSchema = z.discriminatedUnion('type', [
110
170
  ResultMessageSchema,
111
171
  ErrorMessageSchema,
112
172
  InterviewMessageSchema,
173
+ BlockedMessageSchema,
113
174
  ]);
114
175
  export type ServerMessage = z.infer<typeof ServerMessageSchema>;
115
176
 
@@ -12,12 +12,51 @@ 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
 
19
- /** Render a forwarded interview via clack, returning the operator's answer. */
24
+ /** Stable `<scope>.<key>` identity for a forwarded question, when the server sent one. */
25
+ export function interviewKey(iv: InterviewMessage): string | null {
26
+ return iv.scope && iv.key ? `${iv.scope}.${iv.key}` : null;
27
+ }
28
+
29
+ /**
30
+ * Thrown by a renderer that has no way to reach a decider. `runRemoteClient`
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.
34
+ */
35
+ export class InterviewUnanswerableError extends Error {
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = 'InterviewUnanswerableError';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Render a forwarded interview via clack, returning the operator's answer.
44
+ *
45
+ * Refuses to prompt when stdin isn't a TTY. clack reads keypresses off stdin
46
+ * whether or not it is a terminal, so on a piped stdin (an MCP server's
47
+ * JSON-RPC stream, a CI harness) the very next newline submits the prompt at
48
+ * its `initialValue` — i.e. the question's `defaultValue` — and the answer
49
+ * looks exactly like a considered human decision. That is how a live rollout
50
+ * came to report "operator declined" for a breaking update nobody was asked
51
+ * about. A client with no terminal must say so instead of guessing.
52
+ */
20
53
  async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
54
+ if (!process.stdin.isTTY) {
55
+ const key = interviewKey(iv);
56
+ throw new InterviewUnanswerableError(
57
+ `Cannot answer "${iv.message}"${key ? ` (${key})` : ''}: stdin isn't a terminal and no answer was pre-staged.`,
58
+ );
59
+ }
21
60
  const options = (iv.options ?? []).map((o) => ({ value: o.value, label: o.label, hint: o.hint }));
22
61
  let result: unknown;
23
62
  switch (iv.kind) {
@@ -53,7 +92,15 @@ async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
53
92
 
54
93
  /** Minimal transport surface — satisfied by a `node:child_process` spawn of `ssh`. */
55
94
  export interface RemoteTransport {
56
- 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
+ };
57
104
  stdout: ReadableStream<Uint8Array>;
58
105
  kill(): void;
59
106
  exited: Promise<number>;
@@ -88,7 +135,10 @@ function openSshTransport(dest: string): RemoteTransport {
88
135
  const proc = spawn('ssh', ['-T', dest], { stdio: ['pipe', 'pipe', 'inherit'] });
89
136
  if (!proc.stdout || !proc.stdin) throw new Error('ssh was not spawned with piped stdio');
90
137
  return {
91
- 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
+ },
92
142
  stdout: Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
93
143
  kill: () => void proc.kill(),
94
144
  exited: new Promise<number>((resolve) => proc.once('exit', (code) => resolve(code ?? 0))),
@@ -138,6 +188,7 @@ function applyMessage(
138
188
  process.stderr.write(`${msg.error}\n`);
139
189
  return null;
140
190
  case 'interview':
191
+ case 'blocked':
141
192
  // Handled by the caller (needs the transport to reply) — never reached here.
142
193
  return null;
143
194
  case 'result':
@@ -145,36 +196,40 @@ function applyMessage(
145
196
  }
146
197
  }
147
198
 
148
- export async function runRemoteClient(
149
- dest: string,
150
- argv: string[],
151
- opts: {
152
- openTransport?: (dest: string) => RemoteTransport;
153
- out?: DisplayWriter;
154
- renderInterview?: (interview: InterviewMessage) => Promise<unknown>;
155
- } = {},
156
- ): Promise<number> {
157
- if (argv.length === 0) {
158
- process.stderr.write(
159
- 'celilo --remote <dest> requires a command (e.g. celilo --remote host module list)\n',
160
- );
161
- return 2;
162
- }
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 };
163
207
 
164
- const openTransport = opts.openTransport ?? openSshTransport;
165
- const out = opts.out ?? process.stdout;
166
- const renderInterview = opts.renderInterview ?? defaultRenderInterview;
167
- 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.
168
210
 
169
- const transport = openTransport(dest);
170
- transport.stdin.write(`${JSON.stringify({ type: 'command', argv })}\n`);
171
- 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
+ }
172
222
 
173
- 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;
174
229
  const decoder = new TextDecoder();
175
230
  let buffer = '';
176
231
 
177
- outer: for await (const chunk of transport.stdout) {
232
+ for await (const chunk of transport.stdout) {
178
233
  buffer += decoder.decode(chunk, { stream: true });
179
234
  let nl = buffer.indexOf('\n');
180
235
  while (nl >= 0) {
@@ -193,26 +248,126 @@ export async function runRemoteClient(
193
248
  }
194
249
 
195
250
  if (msg.type === 'interview') {
196
- const value = await renderInterview(msg);
197
- transport.stdin.write(`${JSON.stringify({ type: 'answer', id: msg.id, value })}\n`);
251
+ let reply: string;
252
+ try {
253
+ const value = await renderInterview(msg);
254
+ reply = JSON.stringify({ type: 'answer', id: msg.id, value });
255
+ } catch (err) {
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',
260
+ id: msg.id,
261
+ reason: err instanceof Error ? err.message : String(err),
262
+ });
263
+ }
264
+ transport.stdin.write(`${reply}\n`);
198
265
  await transport.stdin.flush?.();
199
266
  continue;
200
267
  }
201
268
 
202
- const exit = applyMessage(msg, display, out);
203
- if (exit !== null) {
204
- resultExit = exit;
205
- 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
+ };
206
283
  }
284
+
285
+ const exit = applyMessage(msg, display, out);
286
+ if (exit !== null) return { status: 'result', exitCode: exit };
207
287
  }
208
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
+ }
298
+
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
+ }
209
338
 
210
- if (resultExit !== null) {
211
- // Server is still looping awaiting more input — tear it down.
212
- transport.kill();
213
- return resultExit;
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;
214
365
  }
215
366
 
216
- // Stream ended without a result — surface the transport's exit (ssh failure).
217
- 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 };
218
373
  }