@gajae-gateway/cli 0.1.1 → 0.1.3

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/src/main.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env bun
2
- import { copyFile, stat } from "node:fs/promises";
2
+ import { Database } from "bun:sqlite";
3
+ import { copyFile, lstat, readFile, stat } from "node:fs/promises";
3
4
  import { isAbsolute, join } from "node:path";
4
5
  import type {
6
+ MonitorEventRecord,
5
7
  MonitorRecord,
8
+ MonitorSpec,
6
9
  OpsCycleResult,
7
- OriginRef,
8
10
  WorkJobsResult,
9
11
  WorkRetireResult,
10
12
  WorkRunResult,
@@ -13,7 +15,7 @@ import type {
13
15
  WorkStatusResult,
14
16
  WorkSteerResult,
15
17
  } from "@gajae-gateway/protocol";
16
- import { LOOPBACK_ORIGIN, originKey, parseOriginKey } from "@gajae-gateway/protocol";
18
+ import { LOOPBACK_ORIGIN, originKey } from "@gajae-gateway/protocol";
17
19
  import { GajaewayClient } from "@gajae-gateway/sdk";
18
20
  import {
19
21
  columnNames,
@@ -24,7 +26,16 @@ import {
24
26
  SESSION_COLUMNS,
25
27
  type SessionListRow,
26
28
  } from "./list";
27
- import { type InstallServicesOptions, installServices, serviceUsage } from "./services";
29
+ import {
30
+ effectiveRestartState,
31
+ type LaunchRestartOptions,
32
+ launchRestartStack,
33
+ type RunRestartOptions,
34
+ readRestartReceipt,
35
+ renderRestartReceipt,
36
+ runRestartStack,
37
+ } from "./restart-stack";
38
+ import { type InstallServicesOptions, installServices, type ServicePlatform, serviceUsage } from "./services";
28
39
 
29
40
  export function socketPath(home = process.env.GAJAEWAY_HOME): string {
30
41
  return `${home ?? `${process.env.HOME ?? "~"}/.gajaeway`}/gateway.sock`;
@@ -50,7 +61,7 @@ export const COMMANDS = [
50
61
  ] as const;
51
62
 
52
63
  export const CLI_USAGE =
53
- "usage: gajaeway [--socket PATH] status|shutdown|chat|daemon run|sessions list [--json] [--fields a,b,c] [--limit N] [--offset N]|sessions inspect <originKey-or-index>|memory audit|memory search <query>|monitors ...|work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] [--notify originKey (start only)] <text>|work status <name>|work steer <name> <text>|work retire <name>|work jobs|ops backup <path>|ops cycle [--json]|ops integrity|ops restore <backupPath>|services install|repair --bin-dir DIR [--launch-agents-dir DIR] (work run waits for a response; caller timeout does not end the attempt)";
64
+ "usage: gajaeway [--socket PATH] status|shutdown|chat|daemon run|sessions list [--json] [--fields a,b,c] [--limit N] [--offset N]|sessions inspect <originKey-or-index>|memory audit|memory search <query>|monitors ... (test <id> [--type T] [--payload J] [--wait[=SECONDS]])|work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] <text>|work status <name>|work steer <name> <text>|work retire <name>|work jobs|ops backup <path>|ops redeliver <deliveryId>|ops redeliver --since <iso>|ops cycle [--json]|ops integrity|ops restore <backupPath>|ops restart-stack [--status]|services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux] (work run waits for a response; caller timeout does not end the attempt)";
54
65
 
55
66
  /** Usage errors exit 2, as `gajaeway-gateway` does; 1 stays a runtime failure. */
56
67
  export const USAGE_EXIT_CODE = 2;
@@ -76,6 +87,11 @@ function gatewayHome(): string {
76
87
 
77
88
  export interface MainOptions {
78
89
  readonly services?: Pick<InstallServicesOptions, "loginPathRunner" | "writeFile">;
90
+ /** Test seams for `ops restart-stack`; the real path spawns the service manager. */
91
+ readonly restartStack?: {
92
+ readonly launch?: Omit<LaunchRestartOptions, "home">;
93
+ readonly run?: Omit<RunRestartOptions, "home" | "id">;
94
+ };
79
95
  }
80
96
 
81
97
  export type ServicesAction = "install" | "repair";
@@ -84,6 +100,8 @@ export interface ParsedServicesArgs {
84
100
  readonly action: ServicesAction;
85
101
  readonly binDir: string;
86
102
  readonly launchAgentsDir?: string;
103
+ readonly unitDir?: string;
104
+ readonly platform?: ServicePlatform;
87
105
  }
88
106
 
89
107
  export function parseServicesArgs(args: readonly string[]): ParsedServicesArgs {
@@ -91,20 +109,164 @@ export function parseServicesArgs(args: readonly string[]): ParsedServicesArgs {
91
109
  if (action !== "install" && action !== "repair") throw new Error(serviceUsage());
92
110
  let binDir: string | undefined;
93
111
  let launchAgentsDir: string | undefined;
112
+ let unitDir: string | undefined;
113
+ let platform: ServicePlatform | undefined;
94
114
  for (let i = 1; i < args.length; i++) {
95
115
  const flag = args[i];
96
- if (flag === "--bin-dir" || flag === "--launch-agents-dir") {
116
+ if (flag === "--bin-dir" || flag === "--launch-agents-dir" || flag === "--unit-dir") {
97
117
  const value = args[++i];
98
118
  if (value === undefined || value.length === 0 || value.startsWith("--"))
99
119
  throw new Error(`${flag} expects a non-empty DIR`);
100
120
  if (flag === "--bin-dir") binDir = value;
121
+ else if (flag === "--unit-dir") unitDir = value;
101
122
  else launchAgentsDir = value;
123
+ } else if (flag === "--platform") {
124
+ const value = args[++i];
125
+ if (value !== "darwin" && value !== "linux") throw new Error("--platform expects darwin or linux");
126
+ platform = value;
102
127
  } else throw new Error(`unknown option: ${flag}`);
103
128
  }
104
129
  if (binDir === undefined) throw new Error("services requires --bin-dir DIR");
105
- return { action, binDir, ...(launchAgentsDir === undefined ? {} : { launchAgentsDir }) };
130
+ return {
131
+ action,
132
+ binDir,
133
+ ...(launchAgentsDir === undefined ? {} : { launchAgentsDir }),
134
+ ...(unitDir === undefined ? {} : { unitDir }),
135
+ ...(platform === undefined ? {} : { platform }),
136
+ };
137
+ }
138
+
139
+ interface ParsedMonitorUpdateArgs {
140
+ readonly monitorId: string;
141
+ readonly params: Record<string, unknown>;
142
+ }
143
+
144
+ const MONITOR_UPDATE_USAGE =
145
+ "usage: gajaeway monitors update <id> (--json '<partial MonitorSpec JSON>'|--schedule '<cron>' [--enabled true|false]|--enabled true|false [--schedule '<cron>'])";
146
+
147
+ function parseMonitorUpdateArgs(args: readonly string[]): ParsedMonitorUpdateArgs {
148
+ const [monitorId, ...options] = args;
149
+ if (!monitorId || monitorId.startsWith("--") || options.length === 0) throw new Error(MONITOR_UPDATE_USAGE);
150
+
151
+ let jsonPatch: Partial<MonitorSpec> | undefined;
152
+ let schedule: string | undefined;
153
+ let enabled: boolean | undefined;
154
+ for (let i = 0; i < options.length; i++) {
155
+ const option = options[i];
156
+ if (option === "--json") {
157
+ const value = options[++i];
158
+ if (jsonPatch !== undefined || value === undefined || value.startsWith("--"))
159
+ throw new Error(MONITOR_UPDATE_USAGE);
160
+ let parsed: unknown;
161
+ try {
162
+ parsed = JSON.parse(value);
163
+ } catch {
164
+ throw new Error(MONITOR_UPDATE_USAGE);
165
+ }
166
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(MONITOR_UPDATE_USAGE);
167
+ jsonPatch = parsed as Partial<MonitorSpec>;
168
+ } else if (option === "--schedule") {
169
+ const value = options[++i];
170
+ if (schedule !== undefined || !value || value.startsWith("--")) throw new Error(MONITOR_UPDATE_USAGE);
171
+ schedule = value;
172
+ } else if (option === "--enabled") {
173
+ const value = options[++i];
174
+ if (enabled !== undefined || (value !== "true" && value !== "false")) throw new Error(MONITOR_UPDATE_USAGE);
175
+ enabled = value === "true";
176
+ } else throw new Error(MONITOR_UPDATE_USAGE);
177
+ }
178
+
179
+ if (jsonPatch !== undefined && (schedule !== undefined || enabled !== undefined))
180
+ throw new Error(MONITOR_UPDATE_USAGE);
181
+ if (jsonPatch !== undefined) return { monitorId, params: { ...jsonPatch, monitorId } };
182
+ return {
183
+ monitorId,
184
+ params: {
185
+ monitorId,
186
+ ...(schedule === undefined ? {} : { schedule }),
187
+ ...(enabled === undefined ? {} : { enabled }),
188
+ },
189
+ };
106
190
  }
107
191
 
192
+ interface ParsedMonitorTestArgs {
193
+ readonly monitorId: string;
194
+ readonly eventType?: string;
195
+ readonly payload: unknown;
196
+ readonly waitSeconds?: number;
197
+ }
198
+
199
+ const MONITOR_TEST_USAGE = "usage: gajaeway monitors test <id> [--type T] [--payload J] [--wait[=SECONDS]]";
200
+ const MONITOR_WAIT_ERROR = `${MONITOR_TEST_USAGE}\n--wait expects a finite non-negative integer up to 86400`;
201
+
202
+ function parseMonitorTestArgs(args: readonly string[]): ParsedMonitorTestArgs {
203
+ const [monitorId, ...flags] = args;
204
+ if (!monitorId || monitorId.startsWith("--")) throw new Error(MONITOR_TEST_USAGE);
205
+ let eventType: string | undefined;
206
+ let payload: unknown = {};
207
+ let waitSeconds: number | undefined;
208
+ const seen = new Set<string>();
209
+ for (let i = 0; i < flags.length; i++) {
210
+ const flag = flags[i];
211
+ if (flag === "--type" || flag === "--payload") {
212
+ if (seen.has(flag)) throw new Error(`${MONITOR_TEST_USAGE}\nduplicate ${flag}`);
213
+ seen.add(flag);
214
+ const value = flags[++i];
215
+ if (value === undefined || value.length === 0 || value.startsWith("--"))
216
+ throw new Error(`${MONITOR_TEST_USAGE}\n${flag} requires a value`);
217
+ if (flag === "--type") eventType = value;
218
+ else payload = JSON.parse(value);
219
+ } else if (flag === "--wait" || flag.startsWith("--wait=")) {
220
+ if (seen.has("--wait")) throw new Error(`${MONITOR_TEST_USAGE}\nduplicate --wait`);
221
+ seen.add("--wait");
222
+ const value = flag === "--wait" ? undefined : flag.slice("--wait=".length);
223
+ if (value === undefined) waitSeconds = 30;
224
+ else {
225
+ if (!/^\d+$/.test(value)) throw new Error(MONITOR_WAIT_ERROR);
226
+ const seconds = Number(value);
227
+ if (!Number.isFinite(seconds) || !Number.isInteger(seconds) || seconds > 86_400)
228
+ throw new Error(MONITOR_WAIT_ERROR);
229
+ waitSeconds = seconds;
230
+ }
231
+ } else throw new Error(`${MONITOR_TEST_USAGE}\nunknown argument: ${flag}`);
232
+ }
233
+ return {
234
+ monitorId,
235
+ ...(eventType === undefined ? {} : { eventType }),
236
+ payload,
237
+ ...(waitSeconds === undefined ? {} : { waitSeconds }),
238
+ };
239
+ }
240
+
241
+ const TERMINAL_MONITOR_STAGES = new Set(["delivered", "authored_no_delivery", "failed", "failed_no_retry"]);
242
+
243
+ function waitForMonitorStage(client: GajaewayClient, eventId: string, timeoutSeconds: number): Promise<string> {
244
+ return new Promise((resolve) => {
245
+ let stage = "admitted";
246
+ let settled = false;
247
+ let timer: ReturnType<typeof setTimeout> | undefined;
248
+ let unsubscribe: (() => void) | undefined;
249
+ const finish = (): void => {
250
+ if (settled) return;
251
+ settled = true;
252
+ if (timer !== undefined) clearTimeout(timer);
253
+ unsubscribe?.();
254
+ resolve(stage);
255
+ };
256
+ unsubscribe = client.on("monitor.event", (payload) => {
257
+ if (typeof payload !== "object" || payload === null) return;
258
+ const event = payload as Partial<MonitorEventRecord>;
259
+ if (event.eventId !== eventId || typeof event.stage !== "string") return;
260
+ stage = event.stage;
261
+ if (TERMINAL_MONITOR_STAGES.has(stage)) finish();
262
+ });
263
+ // GajaewayClient.on may synchronously replay an event held before the
264
+ // monitor.test response; if that event was terminal, clean up the now
265
+ // installed listener rather than starting the timeout.
266
+ if (settled) unsubscribe();
267
+ else timer = setTimeout(finish, timeoutSeconds * 1000);
268
+ });
269
+ }
108
270
  /**
109
271
  * Operator runtime-cycle view (`gajaeway ops cycle`).
110
272
  *
@@ -132,10 +294,23 @@ export function renderCycle(cycle: OpsCycleResult): string[] {
132
294
  lines.push(
133
295
  `memory: queued=${cycle.memoryIntents.queued} written=${cycle.memoryIntents.written} committed=${cycle.memoryIntents.committed} receipted=${cycle.memoryIntents.receipted} quarantined=${cycle.memoryIntents.quarantined}${cycle.memoryClosing ? " (closing)" : ""}`,
134
296
  );
297
+ if (cycle.agentDisk) {
298
+ const { path, freeBytes, totalBytes } = cycle.agentDisk;
299
+ const gib = (bytes: number) => (bytes / 1024 ** 3).toFixed(1);
300
+ lines.push(
301
+ freeBytes === null || totalBytes === null
302
+ ? `agent_disk: ${path} unobservable`
303
+ : `agent_disk: ${path} free=${gib(freeBytes)}GiB total=${gib(totalBytes)}GiB`,
304
+ );
305
+ }
135
306
  // Always rendered: an empty subsystem must be distinguishable from an absent one.
136
307
  lines.push(
137
308
  `monitors: ${cycle.monitorEvents.length ? cycle.monitorEvents.map((m) => `${m.stage}=${m.count}`).join(" ") : "none"}`,
138
309
  );
310
+ if (cycle.monitorAuthoringLost.length > 0)
311
+ lines.push(
312
+ `monitor authoring lost: ${cycle.monitorAuthoringLost.map((m) => `${m.eventType}=${m.consecutive} (last ${m.lastFiredAt})`).join(" ")}`,
313
+ );
139
314
  if (cycle.sessions.length > 0) {
140
315
  lines.push("sessions:");
141
316
  lines.push("INDEX ORIGIN EPOCH SESSION PENDING UNSETTLED OLDEST");
@@ -159,6 +334,87 @@ export function cycleExitCode(cycle: OpsCycleResult): number {
159
334
  return cycle.gates.length > 0 ? 1 : 0;
160
335
  }
161
336
 
337
+ /**
338
+ * The same acceptance the gateway applies at boot (`PRAGMA integrity_check`
339
+ * must answer `ok`), run read-only against the backup before it replaces
340
+ * anything. A 16-byte header match is not a database: a truncated or
341
+ * bit-flipped file passes it, gets copied into place, and the next boot
342
+ * fails while the restore already reported success.
343
+ *
344
+ * An empty file is also refused: SQLite treats a zero-length file as a valid
345
+ * empty database and `integrity_check` answers `ok` for it, but restoring it
346
+ * would boot a gateway with every table freshly created and nothing in them.
347
+ *
348
+ * Opening a WAL-mode backup read-only may leave empty `-wal`/`-shm` sidecars
349
+ * next to the backup; `ops backup` writes delete-mode files, which gain none.
350
+ */
351
+ export function verifyBackupIntegrity(backupPath: string): void {
352
+ let database: Database;
353
+ try {
354
+ database = new Database(backupPath, { readonly: true });
355
+ } catch (error) {
356
+ throw new Error(
357
+ `Backup is not readable: ${backupPath} (${error instanceof Error ? error.message : String(error)})`,
358
+ );
359
+ }
360
+ let detail: string;
361
+ let pageCount: number;
362
+ try {
363
+ pageCount = database.query<{ page_count: number }, []>("PRAGMA page_count").get()?.page_count ?? 0;
364
+ detail =
365
+ database.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get()?.integrity_check ?? "unknown";
366
+ } catch (error) {
367
+ throw new Error(
368
+ `Backup is not a readable SQLite database: ${backupPath} (${error instanceof Error ? error.message : String(error)})`,
369
+ );
370
+ } finally {
371
+ database.close();
372
+ }
373
+ if (pageCount === 0) throw new Error(`Backup is an empty SQLite database: ${backupPath}`);
374
+ if (detail !== "ok") throw new Error(`Backup failed SQLite integrity_check: ${backupPath} (${detail})`);
375
+ }
376
+
377
+ /**
378
+ * The database the gateway actually opens: `config.json` `dbPath` when set,
379
+ * otherwise `<home>/gateway.db` — the same file-based resolution as the
380
+ * gateway's `loadConfig`. An absent `config.json` means defaults, exactly as
381
+ * at boot. A present-but-unreadable one (EACCES, EISDIR, or a dangling symlink,
382
+ * which reports ENOENT while the entry exists) refuses the restore instead of
383
+ * guessing, as boot refuses to start: copying a backup over the wrong file
384
+ * reports success while the live database stays broken.
385
+ *
386
+ * `schemaVersion` is deliberately not checked: `dbPath` is a version-independent
387
+ * string, and disaster recovery must not be blocked by an unrelated schema bump.
388
+ */
389
+ export async function restoreTargetPath(home = gatewayHome()): Promise<string> {
390
+ const configPath = join(home, "config.json");
391
+ let raw: string | undefined;
392
+ try {
393
+ raw = await readFile(configPath, "utf8");
394
+ } catch (error) {
395
+ const code = (error as NodeJS.ErrnoException).code;
396
+ const entryExists = await lstat(configPath).then(
397
+ () => true,
398
+ () => false,
399
+ );
400
+ if (code !== "ENOENT" || entryExists) throw new Error(`Cannot read ${configPath}; refusing restore.`);
401
+ }
402
+ if (raw === undefined) return join(home, "gateway.db");
403
+ let parsed: unknown;
404
+ try {
405
+ parsed = JSON.parse(raw);
406
+ } catch {
407
+ throw new Error(`Cannot parse ${configPath}; refusing restore.`);
408
+ }
409
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
410
+ throw new Error(`Invalid configuration ${configPath}: config must be an object; refusing restore.`);
411
+ const dbPath = (parsed as { dbPath?: unknown }).dbPath;
412
+ if (dbPath === undefined) return join(home, "gateway.db");
413
+ if (typeof dbPath !== "string" || dbPath.length === 0)
414
+ throw new Error(`Invalid configuration ${configPath}: dbPath must be a non-empty string; refusing restore.`);
415
+ return dbPath;
416
+ }
417
+
162
418
  export async function restoreDatabase(socket: string, backupPath: string): Promise<void> {
163
419
  if (!isAbsolute(backupPath)) throw new Error("ops restore requires an absolute backup path");
164
420
  try {
@@ -169,15 +425,8 @@ export async function restoreDatabase(socket: string, backupPath: string): Promi
169
425
  if ((error as NodeJS.ErrnoException).code !== "ENOENT")
170
426
  throw new Error(`Cannot verify gateway socket ${socket}; refusing restore.`);
171
427
  }
172
- let header: Uint8Array;
173
- try {
174
- header = new Uint8Array(await Bun.file(backupPath).slice(0, 16).arrayBuffer());
175
- } catch {
176
- throw new Error(`Backup is not readable: ${backupPath}`);
177
- }
178
- if (new TextDecoder().decode(header) !== "SQLite format 3\u0000")
179
- throw new Error(`Backup is not a SQLite database: ${backupPath}`);
180
- const databasePath = join(gatewayHome(), "gateway.db");
428
+ verifyBackupIntegrity(backupPath);
429
+ const databasePath = await restoreTargetPath();
181
430
  const preservedPath = `${databasePath}.pre-restore-${new Date().toISOString().replace(/[:.]/g, "-")}`;
182
431
  await copyFile(databasePath, preservedPath);
183
432
  await copyFile(backupPath, databasePath);
@@ -186,6 +435,8 @@ export async function restoreDatabase(socket: string, backupPath: string): Promi
186
435
  console.log(`Restored backup to: ${databasePath}`);
187
436
  }
188
437
 
438
+ const CHAT_TURN_TIMEOUT_MS = 30 * 60_000;
439
+
189
440
  async function chat(socket: string): Promise<void> {
190
441
  let client: GajaewayClient;
191
442
  try {
@@ -206,12 +457,15 @@ async function chat(socket: string): Promise<void> {
206
457
  console.error("(message was not engaged)");
207
458
  return;
208
459
  }
460
+ // The gateway's own request wait is 30 minutes; a max-effort reasoning
461
+ // turn routinely runs past two. Match the server so the REPL does not
462
+ // abandon a turn the gateway is still delivering.
209
463
  await new Promise<void>((resolve) => {
210
464
  const timer = setTimeout(() => {
211
465
  turnWaiters.delete(turnId);
212
- console.error("(turn timed out after 120s)");
466
+ console.error(`(turn timed out after ${CHAT_TURN_TIMEOUT_MS / 60_000}m)`);
213
467
  resolve();
214
- }, 120_000);
468
+ }, CHAT_TURN_TIMEOUT_MS);
215
469
  turnWaiters.set(turnId, () => {
216
470
  clearTimeout(timer);
217
471
  turnWaiters.delete(turnId);
@@ -340,9 +594,51 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
340
594
  await restoreDatabase(parsed.socket, path);
341
595
  break;
342
596
  }
597
+ if (command === "restart-stack") {
598
+ // Deliberately socket-free: the reason to run this is a gateway that
599
+ // has to come back, so it must not need the gateway to answer first.
600
+ // It also runs detached: restarting the gateway kills a persona turn that
601
+ // invoked it, which must not end the sequence half-applied (issue #54).
602
+ const usage = "usage: gajaeway ops restart-stack [--status]";
603
+ const home = gatewayHome();
604
+ const flag = parsed.rest[1];
605
+ if (flag === "--run" && parsed.rest.length === 3 && parsed.rest[2]) {
606
+ const receipt = await runRestartStack({
607
+ ...options.restartStack?.run,
608
+ home,
609
+ id: parsed.rest[2],
610
+ });
611
+ for (const line of renderRestartReceipt(receipt)) console.log(line);
612
+ if (receipt.state !== "ok") process.exitCode = 1;
613
+ } else if (flag === "--status" && parsed.rest.length === 2) {
614
+ const receipt = await readRestartReceipt(home);
615
+ if (receipt === undefined) throw new Error("no restart-stack receipt");
616
+ for (const line of renderRestartReceipt(receipt)) console.log(line);
617
+ // Only a completed, verified sequence exits 0; queued or running is not yet success.
618
+ if (effectiveRestartState(receipt) !== "ok") process.exitCode = 1;
619
+ } else if (parsed.rest.length === 1) {
620
+ const { receipt, supervisorPid } = await launchRestartStack({ ...options.restartStack?.launch, home });
621
+ console.log(`restart-stack ${receipt.id}: queued (supervisor pid ${supervisorPid})`);
622
+ console.log("read the outcome with: gajaeway ops restart-stack --status");
623
+ } else throw new Error(usage);
624
+ break;
625
+ }
626
+ let redeliverParams: { deliveryId: string } | { since: string } | undefined;
627
+ if (command === "redeliver") {
628
+ if (parsed.rest.length === 2 && path && path !== "--since") redeliverParams = { deliveryId: path };
629
+ else if (
630
+ parsed.rest.length === 3 &&
631
+ path === "--since" &&
632
+ parsed.rest[2] &&
633
+ Number.isFinite(Date.parse(parsed.rest[2]))
634
+ )
635
+ redeliverParams = { since: parsed.rest[2] };
636
+ else throw new Error("usage: gajaeway ops redeliver <deliveryId>|--since <iso>");
637
+ }
343
638
  const client = await GajaewayClient.connectSocket(parsed.socket);
344
639
  try {
345
640
  if (command === "backup" && path) console.log(JSON.stringify(await client.request("ops.backup", { path })));
641
+ else if (redeliverParams) console.log(JSON.stringify(await client.request("ops.redeliver", redeliverParams)));
346
642
  else if (command === "integrity") console.log(JSON.stringify(await client.request("ops.integrity")));
347
643
  else if (command === "cycle") {
348
644
  const cycle = await client.opsCycle();
@@ -352,7 +648,10 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
352
648
  // Fail-closed: a gated/degraded cycle exits non-zero even though the
353
649
  // request itself succeeded, so scripts can never read it as healthy.
354
650
  process.exitCode = cycleExitCode(cycle);
355
- } else throw new Error("usage: gajaeway ops backup <path>|cycle [--json]|integrity|restore <backupPath>");
651
+ } else
652
+ throw new Error(
653
+ "usage: gajaeway ops backup <path>|redeliver <deliveryId>|redeliver --since <iso>|cycle [--json]|integrity|restore <backupPath>|restart-stack [--status]",
654
+ );
356
655
  } finally {
357
656
  await client.close();
358
657
  }
@@ -384,13 +683,16 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
384
683
  break;
385
684
  }
386
685
  case "monitors": {
387
- const listOptions =
388
- parsed.rest[0] === "list" ? parseListOptions(parsed.rest.slice(1), MONITOR_COLUMNS) : undefined;
686
+ const [command, ...args] = parsed.rest;
687
+ const listOptions = command === "list" ? parseListOptions(args, MONITOR_COLUMNS) : undefined;
688
+ const update = command === "update" ? parseMonitorUpdateArgs(args) : undefined;
689
+ const monitorTest = command === "test" ? parseMonitorTestArgs(args) : undefined;
389
690
  const client = await GajaewayClient.connectSocket(parsed.socket);
390
691
  try {
391
- const [command, ...args] = parsed.rest;
392
692
  if (command === "add" && args[0] === "--json" && args[1])
393
693
  console.log(JSON.stringify(await client.request("monitor.add", JSON.parse(args[1]))));
694
+ else if (command === "update" && update)
695
+ console.log(JSON.stringify(await client.request<{ monitorId: string }>("monitor.update", update.params)));
394
696
  else if (command === "list") {
395
697
  const options = listOptions as ListOptions;
396
698
  const result = await client.request<{ monitors: MonitorRecord[] }>("monitor.list");
@@ -403,25 +705,20 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
403
705
  console.log(JSON.stringify(await client.request("monitor.inspect", { monitorId: args[0] })));
404
706
  else if (command === "remove" && args[0])
405
707
  console.log(JSON.stringify(await client.request("monitor.remove", { monitorId: args[0] })));
406
- else if (command === "test" && args[0]) {
407
- let eventType: string | undefined;
408
- let payload: unknown = {};
409
- for (let i = 1; i < args.length; i++) {
410
- if (args[i] === "--type") eventType = args[++i];
411
- else if (args[i] === "--payload") payload = JSON.parse(args[++i] ?? "");
708
+ else if (command === "test" && monitorTest) {
709
+ const result = await client.request<{ eventId: string }>("monitor.test", {
710
+ monitorId: monitorTest.monitorId,
711
+ ...(monitorTest.eventType === undefined ? {} : { eventType: monitorTest.eventType }),
712
+ payload: monitorTest.payload,
713
+ });
714
+ if (monitorTest.waitSeconds === undefined) console.log(JSON.stringify(result));
715
+ else {
716
+ const stage = await waitForMonitorStage(client, result.eventId, monitorTest.waitSeconds);
717
+ console.log(JSON.stringify({ eventId: result.eventId, stage }));
412
718
  }
413
- console.log(
414
- JSON.stringify(
415
- await client.request("monitor.test", {
416
- monitorId: args[0],
417
- ...(eventType ? { eventType } : {}),
418
- payload,
419
- }),
420
- ),
421
- );
422
719
  } else
423
720
  throw new Error(
424
- `usage: gajaeway monitors add --json '<MonitorSpec json>'|list [--json] [--fields ${columnNames(MONITOR_COLUMNS).join(",")}] [--limit N] [--offset N]|inspect <id>|remove <id>|test <id> [--type T] [--payload J]`,
721
+ `usage: gajaeway monitors add --json '<MonitorSpec json>'|update <id> (--json '<partial MonitorSpec JSON>'|--schedule '<cron>' [--enabled true|false]|--enabled true|false [--schedule '<cron>'])|list [--json] [--fields ${columnNames(MONITOR_COLUMNS).join(",")}] [--limit N] [--offset N]|inspect <id>|remove <id>|test <id> [--type T] [--payload J] [--wait[=SECONDS]]`,
425
722
  );
426
723
  } finally {
427
724
  await client.close();
@@ -431,7 +728,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
431
728
  case "work": {
432
729
  const [command, ...args] = parsed.rest;
433
730
  const usage =
434
- 'usage: gajaeway work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] [--notify originKey (start only)] "<task text>"|status <name>|steer <name> <text>|retire <name>|jobs';
731
+ 'usage: gajaeway work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] "<task text>"|status <name>|steer <name> <text>|retire <name>|jobs';
435
732
  if (command === "status" || command === "steer") {
436
733
  const name = args[0];
437
734
  const text = args.slice(1).join(" ").trim();
@@ -475,8 +772,22 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
475
772
  const state = job.quarantined
476
773
  ? `HELD: quarantined reason=${job.reason} historical_state=${job.state}`
477
774
  : job.state;
775
+ // HEAD is the progress signal that survives a dead op (issue #67).
776
+ const head = job.last_commit
777
+ ? `${job.last_commit.sha.slice(0, 7)}@${job.last_commit.committed_at} ${JSON.stringify(job.last_commit.subject)}`
778
+ : "-";
478
779
  console.log(
479
- `${name} ${state} session=${job.session_id || "-"} last=${job.last_activity_at || "-"} ${job.worktree_path}`,
780
+ `${name} ${state} session=${job.session_id || "-"} accepted=${job.accepted_at || "-"} op=${job.attempt?.op_ref || "-"} last=${job.last_activity_at || "-"} head=${head} ${job.worktree_path}${
781
+ job.reports &&
782
+ (
783
+ job.reports.pending !== 0 ||
784
+ job.reports.claimed !== 0 ||
785
+ job.reports.held !== 0 ||
786
+ job.reports.undeliverable !== 0
787
+ )
788
+ ? ` reports=p:${job.reports.pending} c:${job.reports.claimed} h:${job.reports.held} u:${job.reports.undeliverable}`
789
+ : ""
790
+ }`,
480
791
  );
481
792
  }
482
793
  }
@@ -490,23 +801,15 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
490
801
  let cwd: string | undefined;
491
802
  let resume = false;
492
803
  let model: string | { preset: string } | undefined;
493
- let notify: OriginRef | undefined;
494
804
  const textParts: string[] = [];
495
805
  for (let i = 1; i < args.length; i++) {
496
806
  const arg = args[i];
497
- if (arg === "--cwd" || arg === "--model" || arg === "--preset" || arg === "--notify") {
807
+ if (arg === "--cwd" || arg === "--model" || arg === "--preset") {
498
808
  const value = args[++i];
499
809
  if (!value?.trim() || value.startsWith("--")) throw new Error(usage);
500
810
  if (arg === "--cwd") {
501
811
  if (cwd !== undefined || !isAbsolute(value)) throw new Error(usage);
502
812
  cwd = value;
503
- } else if (arg === "--notify") {
504
- if (command !== "start" || notify !== undefined) throw new Error(usage);
505
- try {
506
- notify = parseOriginKey(value);
507
- } catch {
508
- throw new Error(`${usage}\ninvalid --notify originKey`);
509
- }
510
813
  } else {
511
814
  if (model !== undefined) throw new Error(`${usage}\n--model and --preset are mutually exclusive`);
512
815
  model = arg === "--preset" ? { preset: value } : value;
@@ -523,13 +826,14 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
523
826
  requestTimeoutMs: command === "run" ? 3_600_000 : 120_000,
524
827
  });
525
828
  try {
829
+ const callerSessionId = process.env.GJC_SESSION_ID?.trim();
526
830
  const params: WorkStartParams = {
527
831
  name,
528
832
  text,
529
833
  ...(cwd ? { cwd } : {}),
530
834
  ...(resume ? { resume: true } : {}),
531
835
  ...(model === undefined ? {} : { model }),
532
- ...(notify === undefined ? {} : { notify }),
836
+ ...(callerSessionId ? { callerSessionId } : {}),
533
837
  };
534
838
  if (command === "start") {
535
839
  const result = await client.request<WorkStartResult>("work.start", params);
@@ -558,10 +862,13 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
558
862
  const written = await installServices({
559
863
  binDir: service.binDir,
560
864
  ...(service.launchAgentsDir === undefined ? {} : { launchAgentsDir: service.launchAgentsDir }),
865
+ ...(service.unitDir === undefined ? {} : { unitDir: service.unitDir }),
866
+ ...(service.platform === undefined ? {} : { platform: service.platform }),
561
867
  env: process.env,
562
868
  ...options.services,
563
869
  });
564
- console.log(`services ${service.action}: wrote ${written.length} LaunchAgent plists`);
870
+ console.log(`services ${service.action}: wrote ${written.length} service definitions`);
871
+ for (const definition of written) console.log(definition);
565
872
  break;
566
873
  }
567
874
  default: