@gajae-gateway/cli 0.1.0 → 0.1.2
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/dist/main.d.ts +38 -2
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +617 -66
- package/dist/restart-stack.d.ts +101 -0
- package/dist/restart-stack.d.ts.map +1 -0
- package/dist/services.d.ts +32 -1
- package/dist/services.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/main.ts +358 -51
- package/src/restart-stack.ts +361 -0
- package/src/services.ts +138 -15
package/src/main.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
173
|
-
|
|
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(
|
|
466
|
+
console.error(`(turn timed out after ${CHAT_TURN_TIMEOUT_MS / 60_000}m)`);
|
|
213
467
|
resolve();
|
|
214
|
-
},
|
|
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
|
|
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
|
|
388
|
-
|
|
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" &&
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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]
|
|
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"
|
|
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
|
-
...(
|
|
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}
|
|
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:
|