@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 +1 -1
- package/src/command-registry.ts +17 -0
- package/src/protocol.ts +57 -12
- package/src/remote-client.ts +159 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/core",
|
|
3
|
-
"version": "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",
|
package/src/command-registry.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
package/src/remote-client.ts
CHANGED
|
@@ -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 {
|
|
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 `
|
|
27
|
-
*
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
251
|
+
let reply: string;
|
|
230
252
|
try {
|
|
231
|
-
|
|
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
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
type: '
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
};
|
|
261
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
262
|
+
});
|
|
241
263
|
}
|
|
242
|
-
transport.stdin.write(`${
|
|
264
|
+
transport.stdin.write(`${reply}\n`);
|
|
243
265
|
await transport.stdin.flush?.();
|
|
244
266
|
continue;
|
|
245
267
|
}
|
|
246
268
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
//
|
|
262
|
-
|
|
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
|
}
|