@gajae-gateway/cli 0.1.8 → 0.2.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/src/main.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env bun
2
- import { Database } from "bun:sqlite";
3
- import { copyFile, lstat, readFile, stat } from "node:fs/promises";
2
+
4
3
  import { isAbsolute, join } from "node:path";
4
+
5
5
  import type {
6
6
  MonitorEventRecord,
7
7
  MonitorRecord,
8
8
  MonitorScheduleProjection,
9
9
  MonitorSpec,
10
10
  OpsCycleResult,
11
+ OpsDeliveriesParams,
12
+ OpsDeliveriesResult,
13
+ OpsSloParams,
14
+ OpsSloResult,
15
+ WorkJobsParams,
11
16
  WorkJobsResult,
12
17
  WorkRetireResult,
13
18
  WorkRunResult,
@@ -16,8 +21,11 @@ import type {
16
21
  WorkStatusResult,
17
22
  WorkSteerResult,
18
23
  } from "@gajae-gateway/protocol";
19
- import { LOOPBACK_ORIGIN, originKey } from "@gajae-gateway/protocol";
24
+ import { LOOPBACK_ORIGIN, originKey, WORK_JOBS_MAX_LIMIT } from "@gajae-gateway/protocol";
25
+
20
26
  import { GajaewayClient } from "@gajae-gateway/sdk";
27
+ import pkg from "../package.json";
28
+
21
29
  import {
22
30
  columnNames,
23
31
  type ListOptions,
@@ -41,7 +49,13 @@ import {
41
49
  import { type InstallServicesOptions, installServices, type ServicePlatform, serviceUsage } from "./services";
42
50
  import { type RunSetupOptions, runSetup } from "./setup";
43
51
  import { parseUpdateArgs, renderUpdate, runUpdate, type UpdateDeps } from "./update";
44
- import { performUpgrade } from "./upgrade";
52
+
53
+ async function connectGatewaySocket(
54
+ path: string,
55
+ options: { readonly requestTimeoutMs?: number } = {},
56
+ ): Promise<GajaewayClient> {
57
+ return GajaewayClient.connectSocketWithBackoff(path, { ...options, deadlineMs: 30_000 });
58
+ }
45
59
 
46
60
  export function socketPath(home = process.env.GAJAEWAY_HOME): string {
47
61
  return `${home ?? `${process.env.HOME ?? "~"}/.gajaeway`}/gateway.sock`;
@@ -70,7 +84,7 @@ export const COMMANDS = [
70
84
  ] as const;
71
85
 
72
86
  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";
87
+ "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 ... (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
88
 
75
89
  /** Usage errors exit 2, as `gajaeway-gateway` does; 1 stays a runtime failure. */
76
90
  export const USAGE_EXIT_CODE = 2;
@@ -97,7 +111,7 @@ function gatewayHome(): string {
97
111
  export interface MainOptions {
98
112
  readonly services?: Pick<InstallServicesOptions, "loginPathRunner" | "writeFile">;
99
113
  /** Test seams for `setup`: the platform fetch and the prompter. */
100
- readonly setup?: Pick<RunSetupOptions, "fetch" | "prompter" | "env">;
114
+ readonly setup?: Pick<RunSetupOptions, "fetch" | "prompter" | "env" | "binDir" | "runBrokerAgent">;
101
115
  /** Test seams for `ops restart-stack`; the real path spawns the service manager. */
102
116
  readonly restartStack?: {
103
117
  readonly launch?: Omit<LaunchRestartOptions, "home">;
@@ -307,6 +321,10 @@ export function renderCycle(cycle: OpsCycleResult): string[] {
307
321
  lines.push(
308
322
  `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
323
  );
324
+ if (cycle.memoryGitIndexLock) {
325
+ const { path, ageMs } = cycle.memoryGitIndexLock;
326
+ lines.push(`memory_git_index_lock: ${path} age=${Math.round(ageMs)}ms`);
327
+ }
310
328
  if (cycle.agentDisk) {
311
329
  const { path, freeBytes, totalBytes } = cycle.agentDisk;
312
330
  const gib = (bytes: number) => (bytes / 1024 ** 3).toFixed(1);
@@ -316,6 +334,19 @@ export function renderCycle(cycle: OpsCycleResult): string[] {
316
334
  : `agent_disk: ${path} free=${gib(freeBytes)}GiB total=${gib(totalBytes)}GiB`,
317
335
  );
318
336
  }
337
+ if (cycle.brokerAgent !== null) {
338
+ lines.push(`broker_agent: ${cycle.brokerAgent.status}`);
339
+ for (const entry of cycle.brokerAgent.entries)
340
+ if (entry.state !== "in_sync") lines.push(`broker_agent ${entry.name}: kind=${entry.kind} state=${entry.state}`);
341
+ }
342
+ const sloState = cycle.slo.ok
343
+ ? "ok"
344
+ : cycle.slo.days.some((day) => day.status === "failed")
345
+ ? "failed"
346
+ : cycle.slo.days.some((day) => day.status === "insufficient")
347
+ ? "insufficient"
348
+ : "pending";
349
+ lines.push(`slo: seven-day=${sloState}`);
319
350
  // Always rendered: an empty subsystem must be distinguishable from an absent one.
320
351
  lines.push(
321
352
  `monitors: ${cycle.monitorEvents.length ? cycle.monitorEvents.map((m) => `${m.stage}=${m.count}`).join(" ") : "none"}`,
@@ -346,106 +377,110 @@ export function renderCycle(cycle: OpsCycleResult): string[] {
346
377
  export function cycleExitCode(cycle: OpsCycleResult): number {
347
378
  return cycle.gates.length > 0 ? 1 : 0;
348
379
  }
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)})`,
380
+ export function renderSlo(result: OpsSloResult): string[] {
381
+ const message = result.ok
382
+ ? `SLO ok: ${result.days.length} consecutive UTC days`
383
+ : result.days.some((day) => day.status === "failed")
384
+ ? "SLO failed: counted failure events or journal overflow"
385
+ : result.days.some((day) => day.status === "insufficient")
386
+ ? "SLO insufficient: coverage or durable evidence is incomplete"
387
+ : "SLO pending: an update window is still running; rerun later";
388
+ const lines = [message];
389
+ for (const day of result.days)
390
+ lines.push(
391
+ `${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
392
  );
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)})`,
393
+ for (const window of result.windows)
394
+ lines.push(
395
+ `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
396
  );
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})`);
397
+ return lines;
388
398
  }
389
399
 
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.`);
400
+ export function sloExitCode(result: OpsSloResult): number {
401
+ return result.ok ? 0 : 1;
402
+ }
403
+
404
+ const OPS_USAGE =
405
+ "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]";
406
+ function parseOpsSloArgs(args: readonly string[]): { params: OpsSloParams; json: boolean } {
407
+ let days = 7;
408
+ let json = false;
409
+ const seen = new Set<string>();
410
+ for (let index = 0; index < args.length; index++) {
411
+ const flag = args[index];
412
+ if (flag === "--json") {
413
+ if (json) throw new Error(OPS_USAGE);
414
+ json = true;
415
+ continue;
416
+ }
417
+ if (flag !== "--days" || seen.has(flag)) throw new Error(OPS_USAGE);
418
+ seen.add(flag);
419
+ const value = args[++index];
420
+ if (!value || !/^\d+$/.test(value)) throw new Error(OPS_USAGE);
421
+ days = Number(value);
422
+ if (!Number.isSafeInteger(days) || days < 1 || days > 3_650) throw new Error(OPS_USAGE);
414
423
  }
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.`);
424
+ return { params: { days }, json };
425
+ }
426
+
427
+ function parseOpsDeliveriesArgs(args: readonly string[]): { params: OpsDeliveriesParams; json: boolean } {
428
+ const params: { since?: string; state?: string; limit?: number } = {};
429
+ const seen = new Set<string>();
430
+ let json = false;
431
+ for (let index = 0; index < args.length; index++) {
432
+ const flag = args[index];
433
+ if (flag === "--json") {
434
+ if (json) throw new Error(OPS_USAGE);
435
+ json = true;
436
+ continue;
437
+ }
438
+ if (flag !== "--since" && flag !== "--state" && flag !== "--limit") throw new Error(OPS_USAGE);
439
+ if (seen.has(flag)) throw new Error(OPS_USAGE);
440
+ seen.add(flag);
441
+ const value = args[++index];
442
+ if (!value || value.startsWith("--")) throw new Error(OPS_USAGE);
443
+ if (flag === "--since") {
444
+ if (!Number.isFinite(Date.parse(value))) throw new Error(OPS_USAGE);
445
+ params.since = value;
446
+ } else if (flag === "--state") params.state = value;
447
+ else {
448
+ if (!/^\d+$/.test(value)) throw new Error(OPS_USAGE);
449
+ const limit = Number(value);
450
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) throw new Error(OPS_USAGE);
451
+ params.limit = limit;
452
+ }
421
453
  }
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;
454
+ return { params, json };
429
455
  }
430
456
 
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.`);
457
+ /** `work jobs [--limit N] [--offset N]`, bounded like the gateway so a bad value is a usage error. */
458
+ function parseWorkJobsArgs(args: readonly string[], usage: string): WorkJobsParams {
459
+ const params: { limit?: number; offset?: number } = {};
460
+ for (let index = 0; index < args.length; index++) {
461
+ const flag = args[index];
462
+ const value = args[++index];
463
+ if ((flag !== "--limit" && flag !== "--offset") || value === undefined || !/^\d+$/.test(value))
464
+ throw new Error(usage);
465
+ const key = flag === "--limit" ? "limit" : "offset";
466
+ if (params[key] !== undefined) throw new Error(usage);
467
+ const number = Number(value);
468
+ if (!Number.isSafeInteger(number) || (key === "limit" && (number < 1 || number > WORK_JOBS_MAX_LIMIT)))
469
+ throw new Error(usage);
470
+ params[key] = number;
440
471
  }
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}`);
472
+ return params;
473
+ }
474
+
475
+ export function renderDeliveries(result: OpsDeliveriesResult): string[] {
476
+ if (result.rows.length === 0) return ["deliveries: none"];
477
+ return [
478
+ `deliveries: ${result.rows.length}`,
479
+ ...result.rows.map(
480
+ (row) =>
481
+ `${row.createdAt} ${row.state} delivery=${row.deliveryId} turn=${row.turnId} origin=${row.originKey} conversation=${row.conversationId} text=${JSON.stringify(row.textPrefix)}`,
482
+ ),
483
+ ];
449
484
  }
450
485
 
451
486
  const CHAT_TURN_TIMEOUT_MS = 30 * 60_000;
@@ -453,7 +488,7 @@ const CHAT_TURN_TIMEOUT_MS = 30 * 60_000;
453
488
  async function chat(socket: string): Promise<void> {
454
489
  let client: GajaewayClient;
455
490
  try {
456
- client = await GajaewayClient.connectSocket(socket);
491
+ client = await connectGatewaySocket(socket);
457
492
  } catch {
458
493
  console.error(`Unable to connect to gateway socket ${socket}. Start the daemon out-of-band first.`);
459
494
  process.exitCode = 1;
@@ -510,6 +545,10 @@ async function chat(socket: string): Promise<void> {
510
545
 
511
546
  export async function main(args = process.argv.slice(2), options: MainOptions = {}): Promise<void> {
512
547
  const parsed = parseArgs(args);
548
+ if (parsed.command === "--version" && parsed.rest.length === 0) {
549
+ console.log(pkg.version);
550
+ return;
551
+ }
513
552
  const usage = usageFor(parsed.command);
514
553
  if (usage !== undefined) {
515
554
  console.error(usage);
@@ -518,7 +557,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
518
557
  try {
519
558
  switch (parsed.command) {
520
559
  case "status": {
521
- const client = await GajaewayClient.connectSocket(parsed.socket);
560
+ const client = await connectGatewaySocket(parsed.socket);
522
561
  try {
523
562
  console.log(JSON.stringify(await client.status()));
524
563
  } finally {
@@ -527,7 +566,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
527
566
  break;
528
567
  }
529
568
  case "shutdown": {
530
- const client = await GajaewayClient.connectSocket(parsed.socket);
569
+ const client = await connectGatewaySocket(parsed.socket);
531
570
  try {
532
571
  await client.shutdown();
533
572
  } finally {
@@ -548,7 +587,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
548
587
  case "sessions": {
549
588
  const listOptions =
550
589
  parsed.rest[0] === "list" ? parseListOptions(parsed.rest.slice(1), SESSION_COLUMNS) : undefined;
551
- const client = await GajaewayClient.connectSocket(parsed.socket);
590
+ const client = await connectGatewaySocket(parsed.socket);
552
591
  try {
553
592
  const result = await client.request<{
554
593
  sessions: Array<{
@@ -602,32 +641,14 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
602
641
  break;
603
642
  }
604
643
  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
- }
644
+ const [command, path] = parsed.rest;
645
+ if (
646
+ command === undefined ||
647
+ !["backup", "deliveries", "redeliver", "integrity", "slo", "cycle", "restart-stack"].includes(command)
648
+ )
649
+ throw new Error(`unknown ops command: ${command ?? "(missing)"}\n${OPS_USAGE}`);
650
+ const deliveries = command === "deliveries" ? parseOpsDeliveriesArgs(parsed.rest.slice(1)) : undefined;
651
+ const slo = command === "slo" ? parseOpsSloArgs(parsed.rest.slice(1)) : undefined;
631
652
  if (command === "restart-stack") {
632
653
  // Deliberately socket-free: the reason to run this is a gateway that
633
654
  // has to come back, so it must not need the gateway to answer first.
@@ -647,9 +668,10 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
647
668
  } else if (flag === "--status" && parsed.rest.length === 2) {
648
669
  const receipt = await readRestartReceipt(home);
649
670
  if (receipt === undefined) throw new Error("no restart-stack receipt");
650
- for (const line of renderRestartReceipt(receipt)) console.log(line);
671
+ const state = await effectiveRestartState(receipt, home);
672
+ for (const line of renderRestartReceipt(receipt, state)) console.log(line);
651
673
  // Only a completed, verified sequence exits 0; queued or running is not yet success.
652
- if (effectiveRestartState(receipt) !== "ok") process.exitCode = 1;
674
+ if (state !== "ok") process.exitCode = 1;
653
675
  } else if (parsed.rest.length === 1) {
654
676
  const { receipt, supervisorPid } = await launchRestartStack({ ...options.restartStack?.launch, home });
655
677
  console.log(`restart-stack ${receipt.id}: queued (supervisor pid ${supervisorPid})`);
@@ -669,10 +691,15 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
669
691
  redeliverParams = { since: parsed.rest[2] };
670
692
  else throw new Error("usage: gajaeway ops redeliver <deliveryId>|--since <iso>");
671
693
  }
672
- const client = await GajaewayClient.connectSocket(parsed.socket);
694
+ const client = await connectGatewaySocket(parsed.socket);
673
695
  try {
674
696
  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)));
697
+ else if (deliveries) {
698
+ const result = await client.opsDeliveries(deliveries.params);
699
+ if (deliveries.json) console.log(JSON.stringify(result));
700
+ else for (const line of renderDeliveries(result)) console.log(line);
701
+ } else if (redeliverParams)
702
+ console.log(JSON.stringify(await client.request("ops.redeliver", redeliverParams)));
676
703
  else if (command === "integrity") console.log(JSON.stringify(await client.request("ops.integrity")));
677
704
  else if (command === "cycle") {
678
705
  const cycle = await client.opsCycle();
@@ -682,10 +709,12 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
682
709
  // Fail-closed: a gated/degraded cycle exits non-zero even though the
683
710
  // request itself succeeded, so scripts can never read it as healthy.
684
711
  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
- );
712
+ } else if (slo) {
713
+ const result = await client.opsSlo(slo.params);
714
+ if (slo.json) console.log(JSON.stringify(result));
715
+ else for (const line of renderSlo(result)) console.log(line);
716
+ process.exitCode = sloExitCode(result);
717
+ } else throw new Error(OPS_USAGE);
689
718
  } finally {
690
719
  await client.close();
691
720
  }
@@ -698,7 +727,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
698
727
  // plain audit would read as a repair attempt that reproduced the failure.
699
728
  if ((parsed.rest[0] === "audit" || parsed.rest[0] === "autolink") && parsed.rest.length > 1)
700
729
  throw new Error(`${usage} (unknown argument: ${parsed.rest[1]})`);
701
- const client = await GajaewayClient.connectSocket(parsed.socket);
730
+ const client = await connectGatewaySocket(parsed.socket);
702
731
  try {
703
732
  if (parsed.rest[0] === "audit") {
704
733
  const result = await client.request<{ ok: boolean; issues: unknown[] }>("memory.audit");
@@ -721,7 +750,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
721
750
  const listOptions = command === "list" ? parseListOptions(args, MONITOR_COLUMNS) : undefined;
722
751
  const update = command === "update" ? parseMonitorUpdateArgs(args) : undefined;
723
752
  const monitorTest = command === "test" ? parseMonitorTestArgs(args) : undefined;
724
- const client = await GajaewayClient.connectSocket(parsed.socket);
753
+ const client = await connectGatewaySocket(parsed.socket);
725
754
  try {
726
755
  if (command === "add" && args[0] === "--json" && args[1])
727
756
  console.log(JSON.stringify(await client.request("monitor.add", JSON.parse(args[1]))));
@@ -780,7 +809,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
780
809
  (command === "status" ? args.length !== 1 : !text || args.slice(1).some((arg) => arg.startsWith("--")))
781
810
  )
782
811
  throw new Error(usage);
783
- const client = await GajaewayClient.connectSocket(parsed.socket);
812
+ const client = await connectGatewaySocket(parsed.socket);
784
813
  try {
785
814
  if (command === "status") {
786
815
  console.log(JSON.stringify(await client.request<WorkStatusResult>("work.status", { name })));
@@ -798,6 +827,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
798
827
  let force = false;
799
828
  let allDead = false;
800
829
  let name: string | undefined;
830
+ let jobsPage: WorkJobsParams | undefined;
801
831
  if (command === "retire") {
802
832
  for (let i = 0; i < args.length; i++) {
803
833
  const arg = args[i];
@@ -815,9 +845,9 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
815
845
  if (!allDead && !name) throw new Error(usage);
816
846
  if (allDead && name) throw new Error(usage);
817
847
  } else if (args.length !== 0) {
818
- throw new Error(usage);
848
+ jobsPage = parseWorkJobsArgs(args, usage);
819
849
  }
820
- const client = await GajaewayClient.connectSocket(parsed.socket);
850
+ const client = await connectGatewaySocket(parsed.socket);
821
851
  try {
822
852
  if (command === "retire") {
823
853
  const result = await client.request<WorkRetireResult>("work.retire", {
@@ -836,7 +866,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
836
866
  );
837
867
  }
838
868
  } else {
839
- const result = await client.request<WorkJobsResult>("work.jobs");
869
+ const result = await client.request<WorkJobsResult>("work.jobs", jobsPage);
840
870
  for (const job of result.jobs) {
841
871
  // lane_key is the stored `work-<name>`; print the name work.retire accepts.
842
872
  const name = job.lane_key.startsWith("work-") ? job.lane_key.slice("work-".length) : job.lane_key;
@@ -861,6 +891,12 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
861
891
  }`,
862
892
  );
863
893
  }
894
+ const shownEnd = result.offset + result.jobs.length;
895
+ if (result.jobs.length === 0) console.error(`jobs: none at offset ${result.offset} of ${result.total}`);
896
+ else if (result.offset > 0 || shownEnd < result.total)
897
+ console.error(
898
+ `jobs ${result.offset + 1}-${shownEnd} of ${result.total}${shownEnd < result.total ? ` (next: --offset ${shownEnd})` : ""}`,
899
+ );
864
900
  }
865
901
  } finally {
866
902
  await client.close();
@@ -893,7 +929,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
893
929
  if (!name || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || !text) throw new Error(usage);
894
930
  // This is a caller response-wait budget, never an attempt deadline.
895
931
  // Timeout/disconnect leaves the attempt observable via work.status.
896
- const client = await GajaewayClient.connectSocket(parsed.socket, {
932
+ const client = await connectGatewaySocket(parsed.socket, {
897
933
  requestTimeoutMs: command === "run" ? 3_600_000 : 120_000,
898
934
  });
899
935
  try {
@@ -5,7 +5,6 @@ import {
5
5
  cronMonitor,
6
6
  deliveryOrigin,
7
7
  emitChannels,
8
- emitImportedAxis,
9
8
  emitMonitors,
10
9
  emptyChannelPlan,
11
10
  emptyExtract,
@@ -107,7 +106,6 @@ export async function readHermes(tree: SourceTree, home: string): Promise<Source
107
106
  content: entriesToMarkdown("Long-term memory (imported from Hermes)", memoryText),
108
107
  mtime: info?.mtime,
109
108
  });
110
- emitImportedAxis(extract);
111
109
  }
112
110
 
113
111
  const envPath = join(root, ".env");
package/src/migrate.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
4
+ import { acquireLease, type Lease, LeaseHeldError } from "@gajae-gateway/home";
3
5
  import { detectHermes, readHermes } from "./migrate/hermes";
4
6
  import { detectOpenClaw, readOpenClaw } from "./migrate/openclaw";
5
7
  import {
@@ -23,9 +25,26 @@ export interface MigrateOptions {
23
25
  * reads its configuration, and writes the gajae-way-compatible output.
24
26
  */
25
27
  export async function migrate(options: MigrateOptions): Promise<void> {
26
- const sourceRoot = expandHome(options.source ?? ".");
27
- const targetHome = expandHome(options.target ?? `${process.env.HOME ?? "~"}/.gajaeway`);
28
+ const targetHome = expandHome(options.target ?? process.env.GAJAEWAY_HOME ?? `${process.env.HOME ?? "~"}/.gajaeway`);
29
+ if (options.dryRun) return migrateUnlocked(options, targetHome);
30
+
31
+ let lease: Lease;
32
+ try {
33
+ lease = await acquireLease(targetHome, "memory");
34
+ } catch (error) {
35
+ if (error instanceof LeaseHeldError)
36
+ throw new Error(`${error.message}; stop the gateway first before importing memory data`, { cause: error });
37
+ throw error;
38
+ }
39
+ try {
40
+ await migrateUnlocked(options, targetHome);
41
+ } finally {
42
+ await lease.release();
43
+ }
44
+ }
28
45
 
46
+ async function migrateUnlocked(options: MigrateOptions, targetHome: string): Promise<void> {
47
+ const sourceRoot = expandHome(options.source ?? ".");
29
48
  // Detect source type
30
49
  let sourceType: MigrationSource | undefined;
31
50
  if (await detectHermes(sourceRoot)) {
@@ -109,7 +128,20 @@ export async function migrate(options: MigrateOptions): Promise<void> {
109
128
  for (const file of extract.files) {
110
129
  const filePath = join(targetHome, file.target);
111
130
  await mkdir(join(filePath, ".."), { recursive: true });
112
- await writeFile(filePath, file.content);
131
+ if (file.secret) {
132
+ // Atomic 0600 write for secrets
133
+ const temporary = `${filePath}.${randomUUID()}.tmp`;
134
+ try {
135
+ await writeFile(temporary, file.content, { mode: 0o600 });
136
+ await chmod(temporary, 0o600);
137
+ await rename(temporary, filePath);
138
+ } catch (error) {
139
+ await rm(temporary, { force: true });
140
+ throw error;
141
+ }
142
+ } else {
143
+ await writeFile(filePath, file.content);
144
+ }
113
145
  }
114
146
 
115
147
  // Write merged JSON files