@celilo/cli 0.19.0 → 0.20.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/cli",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,10 +57,10 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1024.0",
60
- "@celilo/capabilities": "^0.10.0",
60
+ "@celilo/capabilities": "^0.11.0",
61
61
  "@celilo/cli-display": "^0.1.10",
62
- "@celilo/core": "^0.4.0",
63
- "@celilo/event-bus": "^0.2.0",
62
+ "@celilo/core": "^0.5.0",
63
+ "@celilo/event-bus": "^0.3.0",
64
64
  "@clack/prompts": "^1.1.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -72,7 +72,7 @@ test('renders a forwarded interview and sends the answer back', async () => {
72
72
  push({ type: 'interview', id: 'q1', kind: 'text', message: 'Hostname?' });
73
73
 
74
74
  const seen: Array<{ id: string }> = [];
75
- const code = await runRemoteClient('ignored', ['module', 'deploy', 'site'], {
75
+ const outcome = await runRemoteClient('ignored', ['module', 'deploy', 'site'], {
76
76
  openTransport: () => transport,
77
77
  out: { write() {} },
78
78
  renderInterview: async (iv) => {
@@ -81,7 +81,7 @@ test('renders a forwarded interview and sends the answer back', async () => {
81
81
  },
82
82
  });
83
83
 
84
- expect(code).toBe(0);
84
+ expect(outcome).toEqual({ status: 'result', exitCode: 0 });
85
85
  expect(seen).toHaveLength(1);
86
86
  expect(seen[0].id).toBe('q1');
87
87
  expect(writes.some((w) => w.includes('"command"'))).toBe(true);
@@ -98,8 +98,12 @@ test('renders a forwarded interview and sends the answer back', async () => {
98
98
  * stream) the next newline submitted the prompt at its `initialValue` — the
99
99
  * question's `defaultValue` — so a breaking update nobody saw came back as a
100
100
  * considered "no". With no terminal the client must say it cannot answer.
101
+ *
102
+ * And it must say so as `unanswerable`, NOT as an `answer` of any shape: an
103
+ * `answer` is what consumes the query and destroys a question nobody decided
104
+ * (celilo#609). The server parks and replies `blocked`.
101
105
  */
102
- test('no TTY and no renderer → answers with an error, never the default', async () => {
106
+ test('no TTY and no renderer → sends unanswerable and returns blocked, never an answer', async () => {
103
107
  const writes: string[] = [];
104
108
  const encoder = new TextEncoder();
105
109
  let controller!: ReadableStreamDefaultController<Uint8Array>;
@@ -114,8 +118,15 @@ test('no TTY and no renderer → answers with an error, never the default', asyn
114
118
  stdin: {
115
119
  write(chunk: string) {
116
120
  writes.push(chunk);
117
- if (chunk.includes('"answer"')) {
118
- push({ type: 'result', success: false, exitCode: 1 });
121
+ // What a #609 server does with "I can't decide": park, don't resolve.
122
+ if (chunk.includes('"unanswerable"')) {
123
+ push({
124
+ type: 'blocked',
125
+ sessionId: 'sess-1',
126
+ eventId: '42',
127
+ question: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?',
128
+ key: 'module-upgrade:iptables.apply_breaking',
129
+ });
119
130
  controller.close();
120
131
  }
121
132
  },
@@ -139,15 +150,26 @@ test('no TTY and no renderer → answers with an error, never the default', asyn
139
150
  // No `renderInterview` — exactly what runRemoteCapture used to do. bun test
140
151
  // runs with a piped stdin, i.e. the MCP server's situation.
141
152
  expect(process.stdin.isTTY).toBeFalsy();
142
- const code = await runRemoteClient('ignored', ['module', 'update'], {
153
+ const outcome = await runRemoteClient('ignored', ['module', 'update'], {
143
154
  openTransport: () => transport,
144
155
  out: { write() {} },
145
156
  });
146
157
 
147
- expect(code).toBe(1);
148
- const answer = writes.find((w) => w.includes('"answer"'));
149
- expect(answer).toBeDefined();
150
- const parsed = JSON.parse(answer as string) as { value: unknown; error?: string };
151
- expect(parsed.value).not.toBe(false);
152
- expect(parsed.error).toContain('module-upgrade:iptables.apply_breaking');
158
+ // Never an answer — that would consume the query.
159
+ expect(writes.find((w) => w.includes('"answer"'))).toBeUndefined();
160
+
161
+ const unanswerable = writes.find((w) => w.includes('"unanswerable"'));
162
+ expect(unanswerable).toBeDefined();
163
+ const parsed = JSON.parse(unanswerable as string) as { id: string; reason: string };
164
+ expect(parsed.id).toBe('q1');
165
+ expect(parsed.reason).toContain('module-upgrade:iptables.apply_breaking');
166
+
167
+ // And the caller is told where it stands rather than being handed a decline.
168
+ expect(outcome).toEqual({
169
+ status: 'blocked',
170
+ sessionId: 'sess-1',
171
+ eventId: '42',
172
+ question: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?',
173
+ key: 'module-upgrade:iptables.apply_breaking',
174
+ });
153
175
  });
package/src/api/serve.ts CHANGED
@@ -2,13 +2,19 @@
2
2
  * Remote API server — `celilo api-serve --principal=<id>` (Slices 1–3).
3
3
  *
4
4
  * Invoked as the sshd forced command for an enrolled key, so it starts already
5
- * authenticated as `principal`. Reads `command`/`answer` messages as NDJSON on
6
- * stdin/stdout, authorizes each command against the principal's grants
7
- * (deny-by-default), and — if allowed — runs it as a child `celilo` process,
8
- * translating the child's protocol-mode output into `progress`/`log` and a
9
- * terminal `result`. While a command runs, a remote responder bridges the event
10
- * bus to the wire so mid-run `interview`s are answered by the client. Every
11
- * attempt is audited to stderr.
5
+ * authenticated as `principal`. Reads `command`/`answer`/`unanswerable`/
6
+ * `attach`/`cancel` messages as NDJSON on stdin/stdout, authorizes each command
7
+ * against the principal's grants (deny-by-default), and — if allowed — runs it
8
+ * as a child `celilo` process, translating the child's protocol-mode output into
9
+ * `progress`/`log` and a terminal `result`. While a command runs, a remote
10
+ * responder bridges the event bus to the wire so mid-run `interview`s are
11
+ * answered by the client. Every attempt is audited to stderr.
12
+ *
13
+ * When the client cannot decide a question, the command **parks**: the query is
14
+ * left unanswered on the bus, the child stays alive past the end of the ssh
15
+ * session, its output is buffered in the session registry, and the client is
16
+ * told `blocked` with the session and query ids (celilo#609). Another responder
17
+ * answers by event id; a later `attach` collects the outcome.
12
18
  */
13
19
 
14
20
  import { createInterface } from 'node:readline';
@@ -16,16 +22,30 @@ import {
16
22
  API_PROTOCOL_VERSION,
17
23
  ClientMessageSchema,
18
24
  type ServerMessage,
25
+ ServerMessageSchema,
19
26
  translateOutputLine,
20
27
  } from '@celilo/core';
21
28
  import { parseArguments } from '../cli/parser';
22
29
  import { getEventBusPath } from '../config/paths';
23
30
  import { isAuthorized } from '../services/api-access';
31
+ import { EVENT_TYPES } from '../services/bus-interview';
24
32
  import { type WireInterview, startRemoteResponder } from '../services/remote-responder';
33
+ import {
34
+ SessionWriter,
35
+ abandonSession,
36
+ expiryReason,
37
+ readSession,
38
+ reapExpiredSessions,
39
+ replayOutput,
40
+ stillParked,
41
+ } from './sessions';
25
42
 
26
43
  /** Exit code returned to the client when authz denies a command. */
27
44
  const EXIT_PERMISSION_DENIED = 126;
28
45
 
46
+ /** How often an attached client polls a session's buffer for new output. */
47
+ const ATTACH_POLL_MS = 250;
48
+
29
49
  function send(msg: ServerMessage): void {
30
50
  process.stdout.write(`${JSON.stringify(msg)}\n`);
31
51
  }
@@ -44,8 +64,20 @@ function opOf(argv: string[]): { command: string; subcommand?: string; label: st
44
64
  return { command: parsed.command, subcommand: parsed.subcommand, label };
45
65
  }
46
66
 
67
+ /**
68
+ * Emit a message to the attached client (if any) AND to the session buffer, so
69
+ * output produced while nobody is attached is not lost.
70
+ */
71
+ function emit(session: SessionWriter, msg: ServerMessage, attached: () => boolean): void {
72
+ session.append(msg);
73
+ if (attached()) send(msg);
74
+ }
75
+
47
76
  /** Drain a child stream line-by-line, forwarding each line as a message. */
48
- async function pumpLines(stream: ReadableStream<Uint8Array>): Promise<void> {
77
+ async function pumpLines(
78
+ stream: ReadableStream<Uint8Array>,
79
+ forward: (msg: ServerMessage) => void,
80
+ ): Promise<void> {
49
81
  const decoder = new TextDecoder();
50
82
  let buffer = '';
51
83
  for await (const chunk of stream) {
@@ -54,17 +86,17 @@ async function pumpLines(stream: ReadableStream<Uint8Array>): Promise<void> {
54
86
  while (nl >= 0) {
55
87
  const line = buffer.slice(0, nl);
56
88
  buffer = buffer.slice(nl + 1);
57
- send(translateOutputLine(line));
89
+ forward(translateOutputLine(line));
58
90
  nl = buffer.indexOf('\n');
59
91
  }
60
92
  }
61
93
  if (buffer.length > 0) {
62
- send(translateOutputLine(buffer));
94
+ forward(translateOutputLine(buffer));
63
95
  }
64
96
  }
65
97
 
66
98
  /** Run an authorized command as a child; returns its exit code. */
67
- async function runCommand(argv: string[]): Promise<number> {
99
+ async function runCommand(argv: string[], forward: (msg: ServerMessage) => void): Promise<number> {
68
100
  // Re-invoke this same CLI as a child. The child is non-TTY (piped stdout) so
69
101
  // ProgressDisplay resolves to protocol mode and emits `[progress:*]` markers.
70
102
  const child = Bun.spawn([process.execPath, Bun.main, ...argv], {
@@ -73,49 +105,132 @@ async function runCommand(argv: string[]): Promise<number> {
73
105
  stderr: 'pipe',
74
106
  });
75
107
 
76
- await Promise.all([pumpLines(child.stdout), pumpLines(child.stderr)]);
108
+ await Promise.all([pumpLines(child.stdout, forward), pumpLines(child.stderr, forward)]);
77
109
  const exitCode = await child.exited;
78
- send({ type: 'result', success: exitCode === 0, exitCode });
110
+ forward({ type: 'result', success: exitCode === 0, exitCode });
79
111
  return exitCode;
80
112
  }
81
113
 
82
- async function handleCommand(
83
- principal: string,
84
- argv: string[],
85
- busDbPath: string,
86
- ask: (interview: WireInterview) => Promise<unknown>,
87
- ): Promise<void> {
88
- const { command, subcommand, label } = opOf(argv);
89
-
90
- if (!(await isAuthorized(principal, command, subcommand))) {
91
- send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
114
+ /**
115
+ * Replay a parked session's buffered output to the attaching client, then tail
116
+ * it until the session goes terminal. Deny-by-default on the owning principal.
117
+ */
118
+ async function handleAttach(principal: string, sessionId: string): Promise<void> {
119
+ const record = readSession(sessionId);
120
+ if (!record) {
121
+ send({ type: 'error', error: `no such session: ${sessionId}` });
122
+ send({ type: 'result', success: false, exitCode: 1 });
123
+ return;
124
+ }
125
+ if (record.principal !== principal) {
126
+ send({
127
+ type: 'error',
128
+ error: `permission denied: session ${sessionId} belongs to another principal`,
129
+ });
92
130
  send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
93
- audit(principal, label, 'deny');
131
+ audit(principal, `attach:${sessionId}`, 'deny');
94
132
  return;
95
133
  }
134
+ audit(principal, `attach:${sessionId}`, 'allow');
135
+
136
+ let cursor = 0;
137
+ for (;;) {
138
+ const { messages, next } = replayOutput(sessionId, cursor);
139
+ cursor = next;
140
+ for (const line of messages) {
141
+ const parsed = ServerMessageSchema.safeParse(JSON.parse(line));
142
+ if (!parsed.success) continue;
143
+ send(parsed.data);
144
+ if (parsed.data.type === 'result') return;
145
+ }
146
+ const current = readSession(sessionId);
147
+ if (!current) return;
148
+ // Still waiting on a decision → say so rather than tailing forever. Asked of
149
+ // the bus, not of the record: the answer arrives from a third party the
150
+ // owning process never hears from, so its `state` lags.
151
+ if (current.parkedEventId && stillParked(current, getEventBusPath())) {
152
+ send({
153
+ type: 'blocked',
154
+ sessionId,
155
+ eventId: current.parkedEventId,
156
+ question: current.question ?? '',
157
+ key: current.questionKey ?? undefined,
158
+ });
159
+ return;
160
+ }
161
+ await new Promise((r) => setTimeout(r, ATTACH_POLL_MS));
162
+ }
163
+ }
96
164
 
97
- // Bridge the bus to the wire so mid-run interviews reach the client.
98
- const responder = startRemoteResponder({ busDbPath, ask, emittedBy: `api:${principal}` });
99
- try {
100
- const exitCode = await runCommand(argv);
101
- audit(principal, label, 'allow', exitCode);
102
- } finally {
103
- responder.close();
165
+ /** Abandon a parked session on the operator's say-so, before its TTL. */
166
+ function handleCancel(principal: string, sessionId: string): void {
167
+ const record = readSession(sessionId);
168
+ if (!record) {
169
+ send({ type: 'error', error: `no such session: ${sessionId}` });
170
+ send({ type: 'result', success: false, exitCode: 1 });
171
+ return;
172
+ }
173
+ if (record.principal !== principal) {
174
+ send({
175
+ type: 'error',
176
+ error: `permission denied: session ${sessionId} belongs to another principal`,
177
+ });
178
+ send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
179
+ audit(principal, `cancel:${sessionId}`, 'deny');
180
+ return;
104
181
  }
182
+ abandonSession(record, {
183
+ busDbPath: getEventBusPath(),
184
+ reason: `Session ${sessionId} was cancelled by ${principal} with "${record.question ?? 'the question'}" still unanswered.`,
185
+ emittedBy: `api:${principal}`,
186
+ });
187
+ audit(principal, `cancel:${sessionId}`, 'allow');
188
+ send({ type: 'result', success: true, exitCode: 0 });
105
189
  }
106
190
 
107
191
  export async function apiServeMode(principal: string): Promise<void> {
108
192
  send({ type: 'ready', protocolVersion: API_PROTOCOL_VERSION });
109
193
 
110
194
  const busDbPath = getEventBusPath();
195
+ // A session whose owning process died (reboot, OOM) would otherwise stay
196
+ // parked forever, holding whatever its command holds.
197
+ reapExpiredSessions({ busDbPath });
198
+
199
+ // When the ssh client goes away, sshd hangs up its forced command. Ignoring
200
+ // SIGHUP is what lets a parked command outlive the session that started it —
201
+ // the TTL reaper, not the transport, is what bounds it now.
202
+ process.on('SIGHUP', () => {});
203
+
111
204
  const pendingAnswers = new Map<
112
205
  string,
113
- { resolve: (value: unknown) => void; reject: (error: Error) => void }
206
+ { resolve: (value: unknown) => void; reject: (error: Error) => void; interview: WireInterview }
114
207
  >();
115
208
 
209
+ /** Set while a command is running; the parked child outlives the transport. */
210
+ const live: { session: SessionWriter | null; clientAttached: boolean } = {
211
+ session: null,
212
+ clientAttached: true,
213
+ };
214
+ let commandRunning: Promise<void> | null = null;
215
+
116
216
  const ask = (interview: WireInterview): Promise<unknown> =>
117
217
  new Promise((resolve, reject) => {
118
- pendingAnswers.set(interview.id, { resolve, reject });
218
+ pendingAnswers.set(interview.id, { resolve, reject, interview });
219
+ if (!live.clientAttached) {
220
+ // Nobody to ask. Re-park on THIS question: a detached command that was
221
+ // answered and moved on is now waiting on a *different* event, and a
222
+ // record still naming the previous one is actively wrong — it hides the
223
+ // live question from `events list-unanswered`, and it aims the TTL
224
+ // reaper at a question that was already decided, so the reaper retires
225
+ // the session while the child is still alive and blocked.
226
+ live.session?.park({
227
+ eventId: interview.id,
228
+ eventType: EVENT_TYPES.interviewRequired(interview.scope, interview.key),
229
+ question: interview.message,
230
+ questionKey: `${interview.scope}.${interview.key}`,
231
+ });
232
+ return;
233
+ }
119
234
  send({
120
235
  type: 'interview',
121
236
  id: interview.id,
@@ -131,6 +246,46 @@ export async function apiServeMode(principal: string): Promise<void> {
131
246
  });
132
247
  });
133
248
 
249
+ const handleCommand = async (argv: string[]): Promise<void> => {
250
+ const { command, subcommand, label } = opOf(argv);
251
+
252
+ if (!(await isAuthorized(principal, command, subcommand))) {
253
+ send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
254
+ send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
255
+ audit(principal, label, 'deny');
256
+ return;
257
+ }
258
+
259
+ const writer = SessionWriter.create({ principal, argv });
260
+ live.session = writer;
261
+ const reaper = setTimeout(
262
+ () => {
263
+ if (writer.current.state !== 'parked') return;
264
+ abandonSession(writer.current, {
265
+ busDbPath,
266
+ reason: expiryReason(writer.current),
267
+ emittedBy: `api:${principal}`,
268
+ });
269
+ writer.finish('abandoned');
270
+ },
271
+ Math.max(0, writer.current.expiresAt - Date.now()),
272
+ );
273
+
274
+ // Bridge the bus to the wire so mid-run interviews reach the client.
275
+ const responder = startRemoteResponder({ busDbPath, ask, emittedBy: `api:${principal}` });
276
+ try {
277
+ const exitCode = await runCommand(argv, (msg) =>
278
+ emit(writer, msg, () => live.clientAttached),
279
+ );
280
+ writer.finish('finished');
281
+ audit(principal, label, 'allow', exitCode);
282
+ } finally {
283
+ clearTimeout(reaper);
284
+ responder.close();
285
+ live.session = null;
286
+ }
287
+ };
288
+
134
289
  const rl = createInterface({ input: process.stdin, terminal: false });
135
290
 
136
291
  for await (const line of rl) {
@@ -148,20 +303,61 @@ export async function apiServeMode(principal: string): Promise<void> {
148
303
  const pending = pendingAnswers.get(msg.id);
149
304
  if (pending) {
150
305
  pendingAnswers.delete(msg.id);
151
- // `error` = the client couldn't reach a decider. Fail the waiting
152
- // command rather than letting the question fall back to its default.
153
- if (msg.error) pending.reject(new Error(msg.error));
154
- else pending.resolve(msg.value);
306
+ live.session?.unpark();
307
+ pending.resolve(msg.value);
308
+ }
309
+ continue;
310
+ }
311
+
312
+ if (msg.type === 'unanswerable') {
313
+ // The client cannot decide. Do NOT resolve the question — park on it and
314
+ // tell the client where it stands. The responder, seeing `ask` reject,
315
+ // emits nothing on the bus, so the query is still there to be answered.
316
+ const pending = pendingAnswers.get(msg.id);
317
+ if (!pending) continue;
318
+ pendingAnswers.delete(msg.id);
319
+ const { interview } = pending;
320
+ pending.reject(new Error(msg.reason));
321
+ if (live.session) {
322
+ live.session.park({
323
+ eventId: msg.id,
324
+ eventType: EVENT_TYPES.interviewRequired(interview.scope, interview.key),
325
+ question: interview.message,
326
+ questionKey: `${interview.scope}.${interview.key}`,
327
+ });
328
+ send({
329
+ type: 'blocked',
330
+ sessionId: live.session.id,
331
+ eventId: msg.id,
332
+ question: interview.message,
333
+ key: `${interview.scope}.${interview.key}`,
334
+ });
155
335
  }
156
336
  continue;
157
337
  }
158
338
 
339
+ if (msg.type === 'attach') {
340
+ await handleAttach(principal, msg.sessionId);
341
+ continue;
342
+ }
343
+
344
+ if (msg.type === 'cancel') {
345
+ handleCancel(principal, msg.sessionId);
346
+ continue;
347
+ }
348
+
159
349
  if (msg.type === 'command') {
160
350
  // Fire-and-forget so the read loop keeps consuming `answer` messages while
161
351
  // the command runs — mid-run interviews are answered in-flight.
162
- void handleCommand(principal, msg.argv, busDbPath, ask);
352
+ commandRunning = handleCommand(msg.argv);
353
+ void commandRunning;
163
354
  }
164
355
  }
165
356
 
357
+ // stdin closed: the client is gone. A parked command must NOT die with it —
358
+ // that is the bug. Stay alive (output goes to the session buffer) until the
359
+ // command finishes or the reaper abandons it.
360
+ live.clientAttached = false;
361
+ if (commandRunning) await commandRunning;
166
362
  process.exit(0);
167
363
  }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Does the reaper actually fire, and does the command say the right thing?
3
+ *
4
+ * A parked session holds a live child process and possibly a module-operation
5
+ * lock. The TTL reaper is the only thing standing between that and the 20-day
6
+ * lock outage `module operations` was built for — so a reaper nobody has watched
7
+ * fire is decorative. This drives a real `module update` sweep into a park,
8
+ * expires its session deliberately, and asserts on what comes back.
9
+ *
10
+ * What it asserts is the *value*, not liveness. Three outcomes are confusable
11
+ * here and only one is correct:
12
+ *
13
+ * declined — someone said no. (`{value: false}`)
14
+ * unanswered — nobody was listening at all. (responder-probe throw)
15
+ * abandoned — the question stood; the deadline passed; nobody decided.
16
+ *
17
+ * Reporting an abandonment as "operator declined" is the original celilo#609
18
+ * bug wearing a different hat, so that string is asserted absent.
19
+ *
20
+ * WATCHED RED: with `abandonSession` emitting `{value: false}` instead of
21
+ * `{abandoned}` — i.e. a reaper that resolves the question the old way — this
22
+ * file fails on `expect(report).not.toContain('operator declined')`. With the
23
+ * reaper removed entirely it fails by timeout, the sweep never resuming.
24
+ */
25
+
26
+ import { afterEach, beforeEach, expect, test } from 'bun:test';
27
+ import { mkdtempSync, rmSync } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join } from 'node:path';
30
+ import { type Bus, type BusEvent, defineEvents, openBus } from '@celilo/event-bus';
31
+ import { handleModuleUpdate } from '../cli/commands/module-update';
32
+ import { getDb } from '../db/client';
33
+ import { modules } from '../db/schema';
34
+ import { RESPONDER_PROBE_EVENT } from '../services/responder-probe';
35
+ import { SessionWriter, listSessions, reapExpiredSessions } from './sessions';
36
+
37
+ const NO_SCHEMAS = defineEvents({});
38
+ const QUERY_TYPE = 'interview.required.module-upgrade:iptables.apply_breaking';
39
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
40
+
41
+ let dir: string;
42
+ let busDbPath: string;
43
+ let server: ReturnType<typeof Bun.serve>;
44
+ let registryUrl: string;
45
+
46
+ /** A responder that proves liveness but never answers the question. */
47
+ function probeOnlyResponder(dbPath: string): { bus: Bus; close: () => void } {
48
+ const bus = openBus({ dbPath, events: NO_SCHEMAS });
49
+ const watch = bus.watch(RESPONDER_PROBE_EVENT, async (event) => {
50
+ if (event.replyFor !== null) return;
51
+ bus.emitRaw(
52
+ `${event.type}.reply`,
53
+ { kind: 'daemon', emittedBy: 'reaper-test' },
54
+ { replyFor: event.id, emittedBy: 'reaper-test' },
55
+ );
56
+ });
57
+ return {
58
+ bus,
59
+ close: () => {
60
+ watch.close();
61
+ bus.close();
62
+ },
63
+ };
64
+ }
65
+
66
+ beforeEach(() => {
67
+ dir = mkdtempSync(join(tmpdir(), 'celilo-reap-'));
68
+ busDbPath = join(dir, 'events.db');
69
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
70
+ process.env.CELILO_DATA_DIR = dir;
71
+ process.env.CELILO_ORIGINAL_CWD = dir;
72
+ process.env.EVENT_BUS_DB = busDbPath;
73
+
74
+ getDb()
75
+ .insert(modules)
76
+ .values({
77
+ id: 'iptables',
78
+ name: 'iptables',
79
+ sourcePath: join(dir, 'installed'),
80
+ version: '1.0.2+9',
81
+ manifestData: { celilo_contract: '1.0', id: 'iptables', name: 'iptables', version: '1.0.2' },
82
+ })
83
+ .run();
84
+
85
+ server = Bun.serve({
86
+ port: 0,
87
+ fetch(req) {
88
+ if (new URL(req.url).pathname === '/index/ip/ta/iptables') {
89
+ return new Response(
90
+ `${JSON.stringify({ name: 'iptables', vers: '2.0.0+1', deps: [], cksum: 'x' })}\n`,
91
+ );
92
+ }
93
+ return new Response('not found', { status: 404 });
94
+ },
95
+ });
96
+ registryUrl = `http://localhost:${server.port}`;
97
+ });
98
+
99
+ afterEach(() => {
100
+ server.stop(true);
101
+ rmSync(dir, { recursive: true, force: true });
102
+ process.env.CELILO_DB_PATH = undefined;
103
+ process.env.CELILO_DATA_DIR = undefined;
104
+ process.env.CELILO_ORIGINAL_CWD = undefined;
105
+ process.env.EVENT_BUS_DB = undefined;
106
+ });
107
+
108
+ test('an expired session is reaped and the command reports abandoned, not declined', async () => {
109
+ const responder = probeOnlyResponder(busDbPath);
110
+ const observer = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
111
+
112
+ try {
113
+ let settled = false;
114
+ const sweep = handleModuleUpdate([], { registry: registryUrl }).then((r) => {
115
+ settled = true;
116
+ return r;
117
+ });
118
+
119
+ await sleep(600);
120
+ expect(settled).toBe(false);
121
+
122
+ const query = observer.recentEvents({ type: QUERY_TYPE })[0] as BusEvent;
123
+ expect(query).toBeDefined();
124
+
125
+ // A session parked on that question, already past its deadline.
126
+ const session = SessionWriter.create({
127
+ principal: 'tester',
128
+ argv: ['module', 'update'],
129
+ ttlMs: -1,
130
+ });
131
+ session.park({
132
+ eventId: String(query.id),
133
+ eventType: QUERY_TYPE,
134
+ question: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?',
135
+ questionKey: 'module-upgrade:iptables.apply_breaking',
136
+ });
137
+
138
+ const reaped = reapExpiredSessions({ busDbPath });
139
+ expect(reaped.map((r) => r.sessionId)).toEqual([session.id]);
140
+
141
+ // The command resumes — and says nobody decided, not that anyone declined.
142
+ const result = await sweep;
143
+ const report = result.success ? (result.message ?? '') : (result.error ?? '');
144
+ expect(report).toContain('Abandoned:');
145
+ expect(report).toContain('never decided');
146
+ expect(report).not.toContain('operator declined');
147
+ // ...and not the *unanswered* wording either: a responder was listening.
148
+ expect(report).not.toContain('could not be answered');
149
+ expect(report).toContain('iptables');
150
+ // A breaking update that silently didn't land must not read as success.
151
+ expect(result.success).toBe(false);
152
+ // The module is untouched — nothing was applied on nobody's authority.
153
+ expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
154
+
155
+ // The record is retained, so a repeatedly-parking command is detectable.
156
+ const retained = listSessions();
157
+ expect(retained).toHaveLength(1);
158
+ expect(retained[0].state).toBe('abandoned');
159
+ expect(retained[0].question).toContain('iptables');
160
+ } finally {
161
+ observer.close();
162
+ responder.close();
163
+ }
164
+ }, 30_000);
165
+
166
+ test('a question answered before the deadline is never overwritten by the reaper', async () => {
167
+ const observer = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
168
+ try {
169
+ const query = observer.emitRaw(QUERY_TYPE, { scope: 'x', key: 'y' });
170
+ observer.emitRaw(
171
+ `${QUERY_TYPE}.reply`,
172
+ { value: true },
173
+ { replyFor: query.id, emittedBy: 'a-real-decider' },
174
+ );
175
+
176
+ const session = SessionWriter.create({
177
+ principal: 'tester',
178
+ argv: ['module', 'update'],
179
+ ttlMs: -1,
180
+ });
181
+ session.park({
182
+ eventId: String(query.id),
183
+ eventType: QUERY_TYPE,
184
+ question: 'Apply breaking update for iptables?',
185
+ });
186
+
187
+ reapExpiredSessions({ busDbPath });
188
+
189
+ // Exactly one reply, and it is the decision that was actually made.
190
+ const replies = observer.repliesFor(query.id);
191
+ expect(replies).toHaveLength(1);
192
+ expect((replies[0].payload as { value: unknown }).value).toBe(true);
193
+ } finally {
194
+ observer.close();
195
+ }
196
+ });