@gajae-gateway/cli 0.1.8 → 0.2.1

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,13 +1,19 @@
1
1
  #!/usr/bin/env bun
2
- import { Database } from "bun:sqlite";
3
- import { copyFile, lstat, readFile, stat } from "node:fs/promises";
4
- import { isAbsolute, join } from "node:path";
2
+
3
+ import { isAbsolute } from "node:path";
4
+
5
5
  import type {
6
+ MonitorEventOperatorResult,
6
7
  MonitorEventRecord,
7
8
  MonitorRecord,
8
9
  MonitorScheduleProjection,
9
10
  MonitorSpec,
10
11
  OpsCycleResult,
12
+ OpsDeliveriesParams,
13
+ OpsDeliveriesResult,
14
+ OpsSloParams,
15
+ OpsSloResult,
16
+ WorkJobsParams,
11
17
  WorkJobsResult,
12
18
  WorkRetireResult,
13
19
  WorkRunResult,
@@ -16,8 +22,11 @@ import type {
16
22
  WorkStatusResult,
17
23
  WorkSteerResult,
18
24
  } from "@gajae-gateway/protocol";
19
- import { LOOPBACK_ORIGIN, originKey } from "@gajae-gateway/protocol";
25
+ import { LOOPBACK_ORIGIN, originKey, WORK_JOBS_MAX_LIMIT } from "@gajae-gateway/protocol";
26
+
20
27
  import { GajaewayClient } from "@gajae-gateway/sdk";
28
+ import pkg from "../package.json";
29
+
21
30
  import {
22
31
  columnNames,
23
32
  type ListOptions,
@@ -41,7 +50,13 @@ import {
41
50
  import { type InstallServicesOptions, installServices, type ServicePlatform, serviceUsage } from "./services";
42
51
  import { type RunSetupOptions, runSetup } from "./setup";
43
52
  import { parseUpdateArgs, renderUpdate, runUpdate, type UpdateDeps } from "./update";
44
- import { performUpgrade } from "./upgrade";
53
+
54
+ async function connectGatewaySocket(
55
+ path: string,
56
+ options: { readonly requestTimeoutMs?: number } = {},
57
+ ): Promise<GajaewayClient> {
58
+ return GajaewayClient.connectSocketWithBackoff(path, { ...options, deadlineMs: 30_000 });
59
+ }
45
60
 
46
61
  export function socketPath(home = process.env.GAJAEWAY_HOME): string {
47
62
  return `${home ?? `${process.env.HOME ?? "~"}/.gajaeway`}/gateway.sock`;
@@ -70,7 +85,7 @@ export const COMMANDS = [
70
85
  ] as const;
71
86
 
72
87
  export const CLI_USAGE =
73
- "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] [--notify originKey (start only)] <text>|work status <name>|work steer <name> <text>|work retire [--force] <name>|work retire --all-dead|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]|migrate [--source PATH] [--target PATH] [--dry-run]|update [--check] [--force] [--bin-dir DIR] [--no-restart]|setup [--from-env] [--adapters discord,slack,telegram] [--owner ID] [--discord-app-id ID]|setup --status (work run waits for a response; caller timeout does not end the attempt); cron timezone is an IANA zone and defaults to the gateway host's local timezone";
88
+ "usage: gajaeway [--version] [--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 ... (settle <eventId>|retry <eventId>|test <id> [--type T] [--payload J] [--wait[=SECONDS]])|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 [--force] <name>|work retire --all-dead|work jobs [--limit N] [--offset N]|ops backup <path>|ops deliveries [--since ISO] [--state S] [--limit N] [--json]|ops redeliver <deliveryId>|ops redeliver --since <iso>|ops cycle [--json]|ops integrity|ops restart-stack [--status]|services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux]|migrate [--source PATH] [--target PATH] [--dry-run]|update [--check] [--force] [--bin-dir DIR] [--no-restart]|setup [--from-env] [--adapters discord,slack,telegram] [--owner ID] [--discord-app-id ID]|setup --status|setup --broker-agent [--from DIR] (work run waits for a response; caller timeout does not end the attempt); cron timezone is an IANA zone and defaults to the gateway host's local timezone";
74
89
 
75
90
  /** Usage errors exit 2, as `gajaeway-gateway` does; 1 stays a runtime failure. */
76
91
  export const USAGE_EXIT_CODE = 2;
@@ -97,7 +112,7 @@ function gatewayHome(): string {
97
112
  export interface MainOptions {
98
113
  readonly services?: Pick<InstallServicesOptions, "loginPathRunner" | "writeFile">;
99
114
  /** Test seams for `setup`: the platform fetch and the prompter. */
100
- readonly setup?: Pick<RunSetupOptions, "fetch" | "prompter" | "env">;
115
+ readonly setup?: Pick<RunSetupOptions, "fetch" | "prompter" | "env" | "binDir" | "runBrokerAgent">;
101
116
  /** Test seams for `ops restart-stack`; the real path spawns the service manager. */
102
117
  readonly restartStack?: {
103
118
  readonly launch?: Omit<LaunchRestartOptions, "home">;
@@ -211,6 +226,13 @@ interface ParsedMonitorTestArgs {
211
226
 
212
227
  const MONITOR_TEST_USAGE = "usage: gajaeway monitors test <id> [--type T] [--payload J] [--wait[=SECONDS]]";
213
228
  const MONITOR_WAIT_ERROR = `${MONITOR_TEST_USAGE}\n--wait expects a finite non-negative integer up to 86400`;
229
+ const MONITOR_OPERATOR_USAGE = "usage: gajaeway monitors settle <eventId>|retry <eventId>";
230
+
231
+ function parseMonitorEventOperatorArgs(args: readonly string[]): string {
232
+ const eventId = args[0];
233
+ if (args.length !== 1 || !eventId?.trim() || eventId.startsWith("--")) throw new Error(MONITOR_OPERATOR_USAGE);
234
+ return eventId;
235
+ }
214
236
 
215
237
  function parseMonitorTestArgs(args: readonly string[]): ParsedMonitorTestArgs {
216
238
  const [monitorId, ...flags] = args;
@@ -251,7 +273,7 @@ function parseMonitorTestArgs(args: readonly string[]): ParsedMonitorTestArgs {
251
273
  };
252
274
  }
253
275
 
254
- const TERMINAL_MONITOR_STAGES = new Set(["delivered", "authored_no_delivery", "failed", "failed_no_retry"]);
276
+ const TERMINAL_MONITOR_STAGES = new Set(["delivered", "authored_no_delivery", "failed", "failed_no_retry", "skipped"]);
255
277
 
256
278
  function waitForMonitorStage(client: GajaewayClient, eventId: string, timeoutSeconds: number): Promise<string> {
257
279
  return new Promise((resolve) => {
@@ -307,6 +329,10 @@ export function renderCycle(cycle: OpsCycleResult): string[] {
307
329
  lines.push(
308
330
  `memory: queued=${cycle.memoryIntents.queued} written=${cycle.memoryIntents.written} committed=${cycle.memoryIntents.committed} receipted=${cycle.memoryIntents.receipted} quarantined=${cycle.memoryIntents.quarantined}${cycle.memoryClosing ? " (closing)" : ""}`,
309
331
  );
332
+ if (cycle.memoryGitIndexLock) {
333
+ const { path, ageMs } = cycle.memoryGitIndexLock;
334
+ lines.push(`memory_git_index_lock: ${path} age=${Math.round(ageMs)}ms`);
335
+ }
310
336
  if (cycle.agentDisk) {
311
337
  const { path, freeBytes, totalBytes } = cycle.agentDisk;
312
338
  const gib = (bytes: number) => (bytes / 1024 ** 3).toFixed(1);
@@ -316,6 +342,19 @@ export function renderCycle(cycle: OpsCycleResult): string[] {
316
342
  : `agent_disk: ${path} free=${gib(freeBytes)}GiB total=${gib(totalBytes)}GiB`,
317
343
  );
318
344
  }
345
+ if (cycle.brokerAgent !== null) {
346
+ lines.push(`broker_agent: ${cycle.brokerAgent.status}`);
347
+ for (const entry of cycle.brokerAgent.entries)
348
+ if (entry.state !== "in_sync") lines.push(`broker_agent ${entry.name}: kind=${entry.kind} state=${entry.state}`);
349
+ }
350
+ const sloState = cycle.slo.ok
351
+ ? "ok"
352
+ : cycle.slo.days.some((day) => day.status === "failed")
353
+ ? "failed"
354
+ : cycle.slo.days.some((day) => day.status === "insufficient")
355
+ ? "insufficient"
356
+ : "pending";
357
+ lines.push(`slo: seven-day=${sloState}`);
319
358
  // Always rendered: an empty subsystem must be distinguishable from an absent one.
320
359
  lines.push(
321
360
  `monitors: ${cycle.monitorEvents.length ? cycle.monitorEvents.map((m) => `${m.stage}=${m.count}`).join(" ") : "none"}`,
@@ -346,106 +385,110 @@ export function renderCycle(cycle: OpsCycleResult): string[] {
346
385
  export function cycleExitCode(cycle: OpsCycleResult): number {
347
386
  return cycle.gates.length > 0 ? 1 : 0;
348
387
  }
349
-
350
- /**
351
- * The same acceptance the gateway applies at boot (`PRAGMA integrity_check`
352
- * must answer `ok`), run read-only against the backup before it replaces
353
- * anything. A 16-byte header match is not a database: a truncated or
354
- * bit-flipped file passes it, gets copied into place, and the next boot
355
- * fails while the restore already reported success.
356
- *
357
- * An empty file is also refused: SQLite treats a zero-length file as a valid
358
- * empty database and `integrity_check` answers `ok` for it, but restoring it
359
- * would boot a gateway with every table freshly created and nothing in them.
360
- *
361
- * Opening a WAL-mode backup read-only may leave empty `-wal`/`-shm` sidecars
362
- * next to the backup; `ops backup` writes delete-mode files, which gain none.
363
- */
364
- export function verifyBackupIntegrity(backupPath: string): void {
365
- let database: Database;
366
- try {
367
- database = new Database(backupPath, { readonly: true });
368
- } catch (error) {
369
- throw new Error(
370
- `Backup is not readable: ${backupPath} (${error instanceof Error ? error.message : String(error)})`,
388
+ export function renderSlo(result: OpsSloResult): string[] {
389
+ const message = result.ok
390
+ ? `SLO ok: ${result.days.length} consecutive UTC days`
391
+ : result.days.some((day) => day.status === "failed")
392
+ ? "SLO failed: counted failure events or journal overflow"
393
+ : result.days.some((day) => day.status === "insufficient")
394
+ ? "SLO insufficient: coverage or durable evidence is incomplete"
395
+ : "SLO pending: an update window is still running; rerun later";
396
+ const lines = [message];
397
+ for (const day of result.days)
398
+ lines.push(
399
+ `${day.date} status=${day.status} coverage=${day.complete ? "complete" : "incomplete"} alive_ms=${day.aliveMs} max_gap_ms=${day.maxUncoveredGapMs} broker_unavailable=${day.broker_unavailable} persona_bind_failed=${day.persona_bind_failed} excluded=${day.excluded}`,
371
400
  );
372
- }
373
- let detail: string;
374
- let pageCount: number;
375
- try {
376
- pageCount = database.query<{ page_count: number }, []>("PRAGMA page_count").get()?.page_count ?? 0;
377
- detail =
378
- database.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get()?.integrity_check ?? "unknown";
379
- } catch (error) {
380
- throw new Error(
381
- `Backup is not a readable SQLite database: ${backupPath} (${error instanceof Error ? error.message : String(error)})`,
401
+ for (const window of result.windows)
402
+ lines.push(
403
+ `window id=${window.id} trigger=${window.trigger} state=${window.state} requested=${window.requestedAt} finished=${window.finishedAt ?? "-"} verified=${window.verified} eligible=${window.eligibleStart ?? "-"}..${window.eligibleEnd ?? "-"} over_cap_ms=${window.overCapMs}`,
382
404
  );
383
- } finally {
384
- database.close();
385
- }
386
- if (pageCount === 0) throw new Error(`Backup is an empty SQLite database: ${backupPath}`);
387
- if (detail !== "ok") throw new Error(`Backup failed SQLite integrity_check: ${backupPath} (${detail})`);
405
+ return lines;
388
406
  }
389
407
 
390
- /**
391
- * The database the gateway actually opens: `config.json` `dbPath` when set,
392
- * otherwise `<home>/gateway.db` — the same file-based resolution as the
393
- * gateway's `loadConfig`. An absent `config.json` means defaults, exactly as
394
- * at boot. A present-but-unreadable one (EACCES, EISDIR, or a dangling symlink,
395
- * which reports ENOENT while the entry exists) refuses the restore instead of
396
- * guessing, as boot refuses to start: copying a backup over the wrong file
397
- * reports success while the live database stays broken.
398
- *
399
- * `schemaVersion` is deliberately not checked: `dbPath` is a version-independent
400
- * string, and disaster recovery must not be blocked by an unrelated schema bump.
401
- */
402
- export async function restoreTargetPath(home = gatewayHome()): Promise<string> {
403
- const configPath = join(home, "config.json");
404
- let raw: string | undefined;
405
- try {
406
- raw = await readFile(configPath, "utf8");
407
- } catch (error) {
408
- const code = (error as NodeJS.ErrnoException).code;
409
- const entryExists = await lstat(configPath).then(
410
- () => true,
411
- () => false,
412
- );
413
- if (code !== "ENOENT" || entryExists) throw new Error(`Cannot read ${configPath}; refusing restore.`);
408
+ export function sloExitCode(result: OpsSloResult): number {
409
+ return result.ok ? 0 : 1;
410
+ }
411
+
412
+ const OPS_USAGE =
413
+ "usage: gajaeway ops backup <path>|deliveries [--since ISO] [--state S] [--limit N] [--json]|redeliver <deliveryId>|redeliver --since <iso>|slo [--days N] [--json]|cycle [--json]|integrity|restart-stack [--status]";
414
+ function parseOpsSloArgs(args: readonly string[]): { params: OpsSloParams; json: boolean } {
415
+ let days = 7;
416
+ let json = false;
417
+ const seen = new Set<string>();
418
+ for (let index = 0; index < args.length; index++) {
419
+ const flag = args[index];
420
+ if (flag === "--json") {
421
+ if (json) throw new Error(OPS_USAGE);
422
+ json = true;
423
+ continue;
424
+ }
425
+ if (flag !== "--days" || seen.has(flag)) throw new Error(OPS_USAGE);
426
+ seen.add(flag);
427
+ const value = args[++index];
428
+ if (!value || !/^\d+$/.test(value)) throw new Error(OPS_USAGE);
429
+ days = Number(value);
430
+ if (!Number.isSafeInteger(days) || days < 1 || days > 3_650) throw new Error(OPS_USAGE);
414
431
  }
415
- if (raw === undefined) return join(home, "gateway.db");
416
- let parsed: unknown;
417
- try {
418
- parsed = JSON.parse(raw);
419
- } catch {
420
- throw new Error(`Cannot parse ${configPath}; refusing restore.`);
432
+ return { params: { days }, json };
433
+ }
434
+
435
+ function parseOpsDeliveriesArgs(args: readonly string[]): { params: OpsDeliveriesParams; json: boolean } {
436
+ const params: { since?: string; state?: string; limit?: number } = {};
437
+ const seen = new Set<string>();
438
+ let json = false;
439
+ for (let index = 0; index < args.length; index++) {
440
+ const flag = args[index];
441
+ if (flag === "--json") {
442
+ if (json) throw new Error(OPS_USAGE);
443
+ json = true;
444
+ continue;
445
+ }
446
+ if (flag !== "--since" && flag !== "--state" && flag !== "--limit") throw new Error(OPS_USAGE);
447
+ if (seen.has(flag)) throw new Error(OPS_USAGE);
448
+ seen.add(flag);
449
+ const value = args[++index];
450
+ if (!value || value.startsWith("--")) throw new Error(OPS_USAGE);
451
+ if (flag === "--since") {
452
+ if (!Number.isFinite(Date.parse(value))) throw new Error(OPS_USAGE);
453
+ params.since = value;
454
+ } else if (flag === "--state") params.state = value;
455
+ else {
456
+ if (!/^\d+$/.test(value)) throw new Error(OPS_USAGE);
457
+ const limit = Number(value);
458
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) throw new Error(OPS_USAGE);
459
+ params.limit = limit;
460
+ }
421
461
  }
422
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
423
- throw new Error(`Invalid configuration ${configPath}: config must be an object; refusing restore.`);
424
- const dbPath = (parsed as { dbPath?: unknown }).dbPath;
425
- if (dbPath === undefined) return join(home, "gateway.db");
426
- if (typeof dbPath !== "string" || dbPath.length === 0)
427
- throw new Error(`Invalid configuration ${configPath}: dbPath must be a non-empty string; refusing restore.`);
428
- return dbPath;
462
+ return { params, json };
429
463
  }
430
464
 
431
- export async function restoreDatabase(socket: string, backupPath: string): Promise<void> {
432
- if (!isAbsolute(backupPath)) throw new Error("ops restore requires an absolute backup path");
433
- try {
434
- await stat(socket);
435
- throw new Error(`Refusing restore: gateway socket ${socket} exists. Stop the daemon before restoring.`);
436
- } catch (error) {
437
- if (error instanceof Error && error.message.startsWith("Refusing restore:")) throw error;
438
- if ((error as NodeJS.ErrnoException).code !== "ENOENT")
439
- throw new Error(`Cannot verify gateway socket ${socket}; refusing restore.`);
465
+ /** `work jobs [--limit N] [--offset N]`, bounded like the gateway so a bad value is a usage error. */
466
+ function parseWorkJobsArgs(args: readonly string[], usage: string): WorkJobsParams {
467
+ const params: { limit?: number; offset?: number } = {};
468
+ for (let index = 0; index < args.length; index++) {
469
+ const flag = args[index];
470
+ const value = args[++index];
471
+ if ((flag !== "--limit" && flag !== "--offset") || value === undefined || !/^\d+$/.test(value))
472
+ throw new Error(usage);
473
+ const key = flag === "--limit" ? "limit" : "offset";
474
+ if (params[key] !== undefined) throw new Error(usage);
475
+ const number = Number(value);
476
+ if (!Number.isSafeInteger(number) || (key === "limit" && (number < 1 || number > WORK_JOBS_MAX_LIMIT)))
477
+ throw new Error(usage);
478
+ params[key] = number;
440
479
  }
441
- verifyBackupIntegrity(backupPath);
442
- const databasePath = await restoreTargetPath();
443
- const preservedPath = `${databasePath}.pre-restore-${new Date().toISOString().replace(/[:.]/g, "-")}`;
444
- await copyFile(databasePath, preservedPath);
445
- await copyFile(backupPath, databasePath);
446
- console.log(`Validated SQLite backup: ${backupPath}`);
447
- console.log(`Copied current database to: ${preservedPath}`);
448
- console.log(`Restored backup to: ${databasePath}`);
480
+ return params;
481
+ }
482
+
483
+ export function renderDeliveries(result: OpsDeliveriesResult): string[] {
484
+ if (result.rows.length === 0) return ["deliveries: none"];
485
+ return [
486
+ `deliveries: ${result.rows.length}`,
487
+ ...result.rows.map(
488
+ (row) =>
489
+ `${row.createdAt} ${row.state} delivery=${row.deliveryId} turn=${row.turnId} origin=${row.originKey} conversation=${row.conversationId} text=${JSON.stringify(row.textPrefix)}`,
490
+ ),
491
+ ];
449
492
  }
450
493
 
451
494
  const CHAT_TURN_TIMEOUT_MS = 30 * 60_000;
@@ -453,7 +496,7 @@ const CHAT_TURN_TIMEOUT_MS = 30 * 60_000;
453
496
  async function chat(socket: string): Promise<void> {
454
497
  let client: GajaewayClient;
455
498
  try {
456
- client = await GajaewayClient.connectSocket(socket);
499
+ client = await connectGatewaySocket(socket);
457
500
  } catch {
458
501
  console.error(`Unable to connect to gateway socket ${socket}. Start the daemon out-of-band first.`);
459
502
  process.exitCode = 1;
@@ -508,8 +551,55 @@ async function chat(socket: string): Promise<void> {
508
551
  }
509
552
  }
510
553
 
554
+ /**
555
+ * Poll for memory operation completion. Memory operations (audit, autolink) are
556
+ * long-running and return an opRef immediately; this function polls for the result.
557
+ * If the CLI wait expires before completion, report 'still running' instead of failure.
558
+ */
559
+ export async function pollMemoryOp(
560
+ client: Pick<GajaewayClient, "request">,
561
+ opRef: string,
562
+ maxWaitMs = 300_000, // 5 min default CLI timeout
563
+ pollIntervalMs = 1_000,
564
+ ): Promise<{ status: "completed" | "failed" | "still_running"; elapsedMs: number; result?: unknown; error?: string }> {
565
+ const startTime = Date.now();
566
+ let operationElapsedMs = 0;
567
+
568
+ while (Date.now() - startTime < maxWaitMs) {
569
+ const status = await client.request<{
570
+ status: "pending" | "completed" | "failed";
571
+ elapsedMs: number;
572
+ result?: unknown;
573
+ error?: string;
574
+ }>("memory.status", { opRef });
575
+ operationElapsedMs = status.elapsedMs;
576
+
577
+ if (status.status === "completed") {
578
+ return { status: "completed", elapsedMs: status.elapsedMs, result: status.result };
579
+ }
580
+
581
+ if (status.status === "failed") {
582
+ return { status: "failed", elapsedMs: status.elapsedMs, error: status.error };
583
+ }
584
+
585
+ // Wait before polling again
586
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
587
+ }
588
+
589
+ // CLI wait expired, but operation may still be running on the server
590
+ const waitedMs = Date.now() - startTime;
591
+ console.error(
592
+ `memory operation still running (${Math.round(operationElapsedMs / 1000)}s operation elapsed; CLI wait ended after ${Math.round(waitedMs / 1000)}s).\nCheck status: gajaeway memory status ${opRef}`,
593
+ );
594
+ return { status: "still_running", elapsedMs: operationElapsedMs };
595
+ }
596
+
511
597
  export async function main(args = process.argv.slice(2), options: MainOptions = {}): Promise<void> {
512
598
  const parsed = parseArgs(args);
599
+ if (parsed.command === "--version" && parsed.rest.length === 0) {
600
+ console.log(pkg.version);
601
+ return;
602
+ }
513
603
  const usage = usageFor(parsed.command);
514
604
  if (usage !== undefined) {
515
605
  console.error(usage);
@@ -518,7 +608,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
518
608
  try {
519
609
  switch (parsed.command) {
520
610
  case "status": {
521
- const client = await GajaewayClient.connectSocket(parsed.socket);
611
+ const client = await connectGatewaySocket(parsed.socket);
522
612
  try {
523
613
  console.log(JSON.stringify(await client.status()));
524
614
  } finally {
@@ -527,7 +617,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
527
617
  break;
528
618
  }
529
619
  case "shutdown": {
530
- const client = await GajaewayClient.connectSocket(parsed.socket);
620
+ const client = await connectGatewaySocket(parsed.socket);
531
621
  try {
532
622
  await client.shutdown();
533
623
  } finally {
@@ -548,7 +638,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
548
638
  case "sessions": {
549
639
  const listOptions =
550
640
  parsed.rest[0] === "list" ? parseListOptions(parsed.rest.slice(1), SESSION_COLUMNS) : undefined;
551
- const client = await GajaewayClient.connectSocket(parsed.socket);
641
+ const client = await connectGatewaySocket(parsed.socket);
552
642
  try {
553
643
  const result = await client.request<{
554
644
  sessions: Array<{
@@ -602,32 +692,14 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
602
692
  break;
603
693
  }
604
694
  case "ops": {
605
- const [command, path, ...extraArgs] = parsed.rest;
606
- if (command === "upgrade") {
607
- // gajaeway ops upgrade [--gjc X.Y.Z]
608
- const home = gatewayHome();
609
- let gjcVersion: string | undefined;
610
- if (path === "--gjc" && extraArgs[0]) {
611
- gjcVersion = extraArgs[0];
612
- } else if (path && path !== "--gjc") {
613
- throw new Error("usage: gajaeway ops upgrade [--gjc X.Y.Z]");
614
- }
615
- const result = await performUpgrade({ home, gjcVersion });
616
- console.log(result.detail);
617
- if (result.status !== "ok") {
618
- process.exitCode = 1;
619
- break;
620
- }
621
- // After upgrade, trigger restart-stack
622
- const { receipt, supervisorPid } = await launchRestartStack({ home });
623
- console.log(`restart-stack ${receipt.id}: queued (supervisor pid ${supervisorPid})`);
624
- console.log("read the outcome with: gajaeway ops restart-stack --status");
625
- break;
626
- }
627
- if (command === "restore" && path) {
628
- await restoreDatabase(parsed.socket, path);
629
- break;
630
- }
695
+ const [command, path] = parsed.rest;
696
+ if (
697
+ command === undefined ||
698
+ !["backup", "deliveries", "redeliver", "integrity", "slo", "cycle", "restart-stack"].includes(command)
699
+ )
700
+ throw new Error(`unknown ops command: ${command ?? "(missing)"}\n${OPS_USAGE}`);
701
+ const deliveries = command === "deliveries" ? parseOpsDeliveriesArgs(parsed.rest.slice(1)) : undefined;
702
+ const slo = command === "slo" ? parseOpsSloArgs(parsed.rest.slice(1)) : undefined;
631
703
  if (command === "restart-stack") {
632
704
  // Deliberately socket-free: the reason to run this is a gateway that
633
705
  // has to come back, so it must not need the gateway to answer first.
@@ -647,9 +719,10 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
647
719
  } else if (flag === "--status" && parsed.rest.length === 2) {
648
720
  const receipt = await readRestartReceipt(home);
649
721
  if (receipt === undefined) throw new Error("no restart-stack receipt");
650
- for (const line of renderRestartReceipt(receipt)) console.log(line);
722
+ const state = await effectiveRestartState(receipt, home);
723
+ for (const line of renderRestartReceipt(receipt, state)) console.log(line);
651
724
  // Only a completed, verified sequence exits 0; queued or running is not yet success.
652
- if (effectiveRestartState(receipt) !== "ok") process.exitCode = 1;
725
+ if (state !== "ok") process.exitCode = 1;
653
726
  } else if (parsed.rest.length === 1) {
654
727
  const { receipt, supervisorPid } = await launchRestartStack({ ...options.restartStack?.launch, home });
655
728
  console.log(`restart-stack ${receipt.id}: queued (supervisor pid ${supervisorPid})`);
@@ -669,10 +742,15 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
669
742
  redeliverParams = { since: parsed.rest[2] };
670
743
  else throw new Error("usage: gajaeway ops redeliver <deliveryId>|--since <iso>");
671
744
  }
672
- const client = await GajaewayClient.connectSocket(parsed.socket);
745
+ const client = await connectGatewaySocket(parsed.socket);
673
746
  try {
674
747
  if (command === "backup" && path) console.log(JSON.stringify(await client.request("ops.backup", { path })));
675
- else if (redeliverParams) console.log(JSON.stringify(await client.request("ops.redeliver", redeliverParams)));
748
+ else if (deliveries) {
749
+ const result = await client.opsDeliveries(deliveries.params);
750
+ if (deliveries.json) console.log(JSON.stringify(result));
751
+ else for (const line of renderDeliveries(result)) console.log(line);
752
+ } else if (redeliverParams)
753
+ console.log(JSON.stringify(await client.request("ops.redeliver", redeliverParams)));
676
754
  else if (command === "integrity") console.log(JSON.stringify(await client.request("ops.integrity")));
677
755
  else if (command === "cycle") {
678
756
  const cycle = await client.opsCycle();
@@ -682,34 +760,76 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
682
760
  // Fail-closed: a gated/degraded cycle exits non-zero even though the
683
761
  // request itself succeeded, so scripts can never read it as healthy.
684
762
  process.exitCode = cycleExitCode(cycle);
685
- } else
686
- throw new Error(
687
- "usage: gajaeway ops backup <path>|redeliver <deliveryId>|redeliver --since <iso>|cycle [--json]|integrity|restore <backupPath>|upgrade [--gjc X.Y.Z]|restart-stack [--status]",
688
- );
763
+ } else if (slo) {
764
+ const result = await client.opsSlo(slo.params);
765
+ if (slo.json) console.log(JSON.stringify(result));
766
+ else for (const line of renderSlo(result)) console.log(line);
767
+ process.exitCode = sloExitCode(result);
768
+ } else throw new Error(OPS_USAGE);
689
769
  } finally {
690
770
  await client.close();
691
771
  }
692
772
  break;
693
773
  }
694
774
  case "memory": {
695
- const usage = "usage: gajaeway memory audit|memory autolink|memory search <query>";
775
+ const usage = "usage: gajaeway memory audit|autolink|search <query>|status <opRef>";
696
776
  // An unrecognised argument is refused before the socket is opened rather
697
777
  // than ignored: a run of `memory audit --fix` that silently degraded to a
698
778
  // plain audit would read as a repair attempt that reproduced the failure.
699
779
  if ((parsed.rest[0] === "audit" || parsed.rest[0] === "autolink") && parsed.rest.length > 1)
700
780
  throw new Error(`${usage} (unknown argument: ${parsed.rest[1]})`);
701
- const client = await GajaewayClient.connectSocket(parsed.socket);
781
+ if (parsed.rest[0] === "status" && parsed.rest.length < 2) throw new Error(`${usage} (status requires opRef)`);
782
+ const client = await connectGatewaySocket(parsed.socket);
702
783
  try {
703
784
  if (parsed.rest[0] === "audit") {
704
- const result = await client.request<{ ok: boolean; issues: unknown[] }>("memory.audit");
705
- console.log(JSON.stringify(result.issues));
706
- if (!result.ok) process.exitCode = 1;
785
+ // Request audit operation; get opRef and poll for completion
786
+ const initResp = await client.request<{ opRef: string }>("memory.audit");
787
+ const poll = await pollMemoryOp(client, initResp.opRef);
788
+ if (poll.status === "failed") {
789
+ console.error(`audit failed after ${poll.elapsedMs}ms: ${poll.error}`);
790
+ process.exitCode = 1;
791
+ } else if (poll.status === "still_running") {
792
+ // Operation is still running; exit with distinct code so it's not reported as failure
793
+ process.exitCode = 75;
794
+ } else {
795
+ const auditResult = poll.result as { ok?: boolean; issues?: unknown[] };
796
+ console.log(JSON.stringify(auditResult?.issues ?? []));
797
+ console.error(`audit completed in ${poll.elapsedMs}ms`);
798
+ if (!auditResult?.ok) process.exitCode = 1;
799
+ }
707
800
  } else if (parsed.rest[0] === "autolink") {
708
- console.log(JSON.stringify(await client.request("memory.autolink")));
801
+ // Request autolink operation; get opRef and poll for completion
802
+ const initResp = await client.request<{ opRef: string }>("memory.autolink");
803
+ const poll = await pollMemoryOp(client, initResp.opRef);
804
+ if (poll.status === "failed") {
805
+ console.error(`autolink failed after ${poll.elapsedMs}ms: ${poll.error}`);
806
+ process.exitCode = 1;
807
+ } else if (poll.status === "still_running") {
808
+ // Operation is still running; exit with distinct code so it's not reported as failure
809
+ process.exitCode = 75;
810
+ } else {
811
+ console.log(JSON.stringify(poll.result));
812
+ }
709
813
  } else if (parsed.rest[0] === "search" && parsed.rest.slice(1).join(" ")) {
710
814
  console.log(
711
815
  JSON.stringify(await client.request("memory.search", { query: parsed.rest.slice(1).join(" ") })),
712
816
  );
817
+ } else if (parsed.rest[0] === "status" && parsed.rest[1]) {
818
+ const opRef = parsed.rest[1];
819
+ const result = await client.request<{
820
+ status: "pending" | "completed" | "failed";
821
+ elapsedMs: number;
822
+ result?: unknown;
823
+ error?: string;
824
+ }>("memory.status", { opRef });
825
+ if (result.status === "failed") {
826
+ console.error(`operation ${opRef} failed after ${result.elapsedMs}ms: ${result.error}`);
827
+ process.exitCode = 1;
828
+ } else if (result.status === "completed") {
829
+ console.log(JSON.stringify(result));
830
+ } else {
831
+ console.log(JSON.stringify({ status: result.status, elapsedMs: result.elapsedMs }));
832
+ }
713
833
  } else throw new Error(usage);
714
834
  } finally {
715
835
  await client.close();
@@ -721,7 +841,9 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
721
841
  const listOptions = command === "list" ? parseListOptions(args, MONITOR_COLUMNS) : undefined;
722
842
  const update = command === "update" ? parseMonitorUpdateArgs(args) : undefined;
723
843
  const monitorTest = command === "test" ? parseMonitorTestArgs(args) : undefined;
724
- const client = await GajaewayClient.connectSocket(parsed.socket);
844
+ const operatorEventId =
845
+ command === "settle" || command === "retry" ? parseMonitorEventOperatorArgs(args) : undefined;
846
+ const client = await connectGatewaySocket(parsed.socket);
725
847
  try {
726
848
  if (command === "add" && args[0] === "--json" && args[1])
727
849
  console.log(JSON.stringify(await client.request("monitor.add", JSON.parse(args[1]))));
@@ -758,9 +880,15 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
758
880
  const stage = await waitForMonitorStage(client, result.eventId, monitorTest.waitSeconds);
759
881
  console.log(JSON.stringify({ eventId: result.eventId, stage }));
760
882
  }
883
+ } else if ((command === "settle" || command === "retry") && operatorEventId !== undefined) {
884
+ const result = await client.request<MonitorEventOperatorResult>(`monitor.${command}`, {
885
+ eventId: operatorEventId,
886
+ });
887
+ console.log(JSON.stringify(result));
888
+ if (!result.done) process.exitCode = 1;
761
889
  } else
762
890
  throw new Error(
763
- `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]]`,
891
+ `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>|settle <eventId>|retry <eventId>|test <id> [--type T] [--payload J] [--wait[=SECONDS]]`,
764
892
  );
765
893
  } finally {
766
894
  await client.close();
@@ -780,7 +908,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
780
908
  (command === "status" ? args.length !== 1 : !text || args.slice(1).some((arg) => arg.startsWith("--")))
781
909
  )
782
910
  throw new Error(usage);
783
- const client = await GajaewayClient.connectSocket(parsed.socket);
911
+ const client = await connectGatewaySocket(parsed.socket);
784
912
  try {
785
913
  if (command === "status") {
786
914
  console.log(JSON.stringify(await client.request<WorkStatusResult>("work.status", { name })));
@@ -798,6 +926,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
798
926
  let force = false;
799
927
  let allDead = false;
800
928
  let name: string | undefined;
929
+ let jobsPage: WorkJobsParams | undefined;
801
930
  if (command === "retire") {
802
931
  for (let i = 0; i < args.length; i++) {
803
932
  const arg = args[i];
@@ -815,9 +944,9 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
815
944
  if (!allDead && !name) throw new Error(usage);
816
945
  if (allDead && name) throw new Error(usage);
817
946
  } else if (args.length !== 0) {
818
- throw new Error(usage);
947
+ jobsPage = parseWorkJobsArgs(args, usage);
819
948
  }
820
- const client = await GajaewayClient.connectSocket(parsed.socket);
949
+ const client = await connectGatewaySocket(parsed.socket);
821
950
  try {
822
951
  if (command === "retire") {
823
952
  const result = await client.request<WorkRetireResult>("work.retire", {
@@ -836,7 +965,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
836
965
  );
837
966
  }
838
967
  } else {
839
- const result = await client.request<WorkJobsResult>("work.jobs");
968
+ const result = await client.request<WorkJobsResult>("work.jobs", jobsPage);
840
969
  for (const job of result.jobs) {
841
970
  // lane_key is the stored `work-<name>`; print the name work.retire accepts.
842
971
  const name = job.lane_key.startsWith("work-") ? job.lane_key.slice("work-".length) : job.lane_key;
@@ -861,6 +990,12 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
861
990
  }`,
862
991
  );
863
992
  }
993
+ const shownEnd = result.offset + result.jobs.length;
994
+ if (result.jobs.length === 0) console.error(`jobs: none at offset ${result.offset} of ${result.total}`);
995
+ else if (result.offset > 0 || shownEnd < result.total)
996
+ console.error(
997
+ `jobs ${result.offset + 1}-${shownEnd} of ${result.total}${shownEnd < result.total ? ` (next: --offset ${shownEnd})` : ""}`,
998
+ );
864
999
  }
865
1000
  } finally {
866
1001
  await client.close();
@@ -871,16 +1006,24 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
871
1006
  const name = args[0];
872
1007
  let cwd: string | undefined;
873
1008
  let resume = false;
1009
+ let repo: string | undefined;
1010
+ let base: string | undefined;
874
1011
  let model: string | { preset: string } | undefined;
875
1012
  const textParts: string[] = [];
876
1013
  for (let i = 1; i < args.length; i++) {
877
1014
  const arg = args[i];
878
- if (arg === "--cwd" || arg === "--model" || arg === "--preset") {
1015
+ if (arg === "--cwd" || arg === "--repo" || arg === "--base" || arg === "--model" || arg === "--preset") {
879
1016
  const value = args[++i];
880
1017
  if (!value?.trim() || value.startsWith("--")) throw new Error(usage);
881
1018
  if (arg === "--cwd") {
882
1019
  if (cwd !== undefined || !isAbsolute(value)) throw new Error(usage);
883
1020
  cwd = value;
1021
+ } else if (arg === "--repo") {
1022
+ if (repo !== undefined) throw new Error(usage);
1023
+ repo = value;
1024
+ } else if (arg === "--base") {
1025
+ if (base !== undefined) throw new Error(usage);
1026
+ base = value;
884
1027
  } else {
885
1028
  if (model !== undefined) throw new Error(`${usage}\n--model and --preset are mutually exclusive`);
886
1029
  model = arg === "--preset" ? { preset: value } : value;
@@ -893,7 +1036,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
893
1036
  if (!name || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || !text) throw new Error(usage);
894
1037
  // This is a caller response-wait budget, never an attempt deadline.
895
1038
  // Timeout/disconnect leaves the attempt observable via work.status.
896
- const client = await GajaewayClient.connectSocket(parsed.socket, {
1039
+ const client = await connectGatewaySocket(parsed.socket, {
897
1040
  requestTimeoutMs: command === "run" ? 3_600_000 : 120_000,
898
1041
  });
899
1042
  try {
@@ -902,6 +1045,8 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
902
1045
  name,
903
1046
  text,
904
1047
  ...(cwd ? { cwd } : {}),
1048
+ ...(repo ? { repo } : {}),
1049
+ ...(base ? { base } : {}),
905
1050
  ...(resume ? { resume: true } : {}),
906
1051
  ...(model === undefined ? {} : { model }),
907
1052
  ...(callerSessionId ? { callerSessionId } : {}),