@akira-tl/forgerelay 0.9.3 → 0.9.4

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.
@@ -0,0 +1,93 @@
1
+ const TERMINAL_ACTIVITY_EVENTS = "'succeeded', 'returned', 'failed', 'blocked'";
2
+ export function eligibleActivityCte(sqlite) {
3
+ if (!tableExists(sqlite, "activity_audit_events") || !tableExists(sqlite, "activity_host_turns")) {
4
+ return `with retention(cutoff) as (values (?)),
5
+ eligible_turns(turn_id) as (select null where false),
6
+ eligible_activities(activity_id) as (select null where false)`;
7
+ }
8
+ const runningBash = tableExists(sqlite, "bash_output_streams")
9
+ ? `exists (
10
+ select 1 from bash_output_streams running_bash
11
+ where running_bash.activity_id = started.activity_id
12
+ and running_bash.status = 'running'
13
+ )`
14
+ : "false";
15
+ const hasSubagents = tableExists(sqlite, "local_agent_sessions");
16
+ const hasActiveRunId = hasSubagents && columnExists(sqlite, "local_agent_sessions", "active_run_id");
17
+ const hasActiveActivityId = hasSubagents && columnExists(sqlite, "local_agent_sessions", "active_activity_id");
18
+ const activeSubagentPredicate = hasSubagents
19
+ ? hasActiveRunId
20
+ ? "(active_subagent.status = 'running' or active_subagent.active_run_id is not null)"
21
+ : "active_subagent.status = 'running'"
22
+ : "false";
23
+ const activeSubagent = hasSubagents && hasActiveActivityId
24
+ ? `exists (
25
+ select 1 from local_agent_sessions active_subagent
26
+ where active_subagent.active_activity_id = started.activity_id
27
+ and ${activeSubagentPredicate}
28
+ )`
29
+ : "false";
30
+ const unknownActiveSubagent = hasSubagents
31
+ ? hasActiveActivityId
32
+ ? `exists (
33
+ select 1 from local_agent_sessions active_subagent
34
+ where ${activeSubagentPredicate}
35
+ and active_subagent.active_activity_id is null
36
+ )`
37
+ : `exists (
38
+ select 1 from local_agent_sessions active_subagent
39
+ where ${activeSubagentPredicate}
40
+ )`
41
+ : "false";
42
+ return `with retention(cutoff) as (values (?)),
43
+ latest_activity_events as (
44
+ select activity_id, max(sequence) as sequence
45
+ from activity_audit_events
46
+ group by activity_id
47
+ ), started_activities as (
48
+ select started.activity_id, started.turn_id
49
+ from activity_audit_events started
50
+ where started.event_type = 'started' and started.turn_id is not null
51
+ ), protected_turns as (
52
+ select distinct started.turn_id
53
+ from started_activities started
54
+ where ${runningBash} or ${activeSubagent}
55
+ ), eligible_turns as (
56
+ select turn_state.turn_id
57
+ from activity_host_turns turn_state, retention
58
+ where turn_state.created_at < retention.cutoff
59
+ and not (${unknownActiveSubagent})
60
+ and turn_state.turn_id not in (select turn_id from protected_turns)
61
+ and not exists (
62
+ select 1
63
+ from started_activities started
64
+ join latest_activity_events latest on latest.activity_id = started.activity_id
65
+ join activity_audit_events latest_event
66
+ on latest_event.activity_id = latest.activity_id
67
+ and latest_event.sequence = latest.sequence
68
+ where started.turn_id = turn_state.turn_id
69
+ and (
70
+ latest_event.created_at >= retention.cutoff
71
+ or latest_event.event_type not in (${TERMINAL_ACTIVITY_EVENTS})
72
+ )
73
+ )
74
+ ), eligible_activities as (
75
+ select started.activity_id
76
+ from started_activities started
77
+ join eligible_turns turn_state on turn_state.turn_id = started.turn_id
78
+ )`;
79
+ }
80
+ export function bashBytesExpression(sqlite) {
81
+ const parts = [columnExists(sqlite, "bash_output_streams", "output_bytes") ? "coalesce(output_bytes, 0)" : "0"];
82
+ if (columnExists(sqlite, "bash_output_streams", "command_length"))
83
+ parts.push("coalesce(command_length, 0)");
84
+ if (columnExists(sqlite, "bash_output_streams", "error_length"))
85
+ parts.push("coalesce(error_length, 0)");
86
+ return parts.join(" + ");
87
+ }
88
+ export function tableExists(sqlite, table) {
89
+ return Boolean(sqlite.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(table));
90
+ }
91
+ export function columnExists(sqlite, table, column) {
92
+ return sqlite.prepare(`pragma table_info(${table})`).all().some((row) => row.name === column);
93
+ }
@@ -0,0 +1,598 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import Database from "better-sqlite3";
6
+ import { expandHomePath } from "../mcp/filesystem/roots.js";
7
+ import { databasePath } from "../runtime/state/db/client.js";
8
+ import { bashBytesExpression, columnExists, eligibleActivityCte, tableExists, } from "./maintenance-retention.js";
9
+ import { printMaintenancePruneReport, pruneMaintenanceState, } from "./maintenance-prune.js";
10
+ import { forgerelayConfigPath, } from "../runtime/config/user-config.js";
11
+ const DAY_MS = 24 * 60 * 60 * 1_000;
12
+ const MAX_RETENTION_DAYS = 36_500;
13
+ const MAX_CHECKPOINT_STATE_BYTES = 1024 * 1024;
14
+ const MAX_TASK_STATE_BYTES = 2 * 1024 * 1024;
15
+ const WORKSPACE_ID = /^(?:ws|rws|cws)_[a-z0-9]+$/i;
16
+ const REVIEW_REF = /^refs\/forgerelay\/review\/([^/]+)\/(?:open|baseline)$/;
17
+ export function runMaintenanceCommand(args, env = process.env) {
18
+ const [subcommand, ...rest] = args;
19
+ if (subcommand === undefined || ["help", "--help", "-h"].includes(subcommand)) {
20
+ printMaintenanceHelp();
21
+ return;
22
+ }
23
+ if (subcommand !== "inspect" && subcommand !== "prune") {
24
+ throw new Error(`Unknown maintenance command: ${subcommand}`);
25
+ }
26
+ const json = rest.includes("--json");
27
+ const unknown = rest.filter((value) => value !== "--json");
28
+ if (unknown.length > 0)
29
+ throw new Error(`Unknown maintenance ${subcommand} option: ${unknown[0]}`);
30
+ const inspection = inspectMaintenanceState(env);
31
+ if (subcommand === "inspect") {
32
+ if (json) {
33
+ console.log(JSON.stringify(inspection, null, 2));
34
+ return;
35
+ }
36
+ printMaintenanceReport(inspection);
37
+ return;
38
+ }
39
+ const report = pruneMaintenanceState(inspection.stateDir, inspection.policy);
40
+ if (json) {
41
+ console.log(JSON.stringify(report, null, 2));
42
+ return;
43
+ }
44
+ printMaintenancePruneReport(report);
45
+ }
46
+ export function inspectMaintenanceState(env = process.env, now = new Date()) {
47
+ const config = readMaintenanceConfig(env);
48
+ const stateDir = resolveMaintenanceStateDir(config, env);
49
+ const policy = resolveMaintenanceRetentionPolicy(config.retention, env);
50
+ const cutoff = policy.historyDays === null
51
+ ? null
52
+ : new Date(now.getTime() - policy.historyDays * DAY_MS).toISOString();
53
+ const databaseSnapshot = openReadOnlyDatabaseSnapshot(stateDir);
54
+ const sqlite = databaseSnapshot?.sqlite;
55
+ try {
56
+ const workspaces = sqlite && tableExists(sqlite, "workspace_sessions")
57
+ ? sqlite.prepare("select id, root, status, mode, source_root, branch, managed from workspace_sessions order by id").all()
58
+ : [];
59
+ const workspaceIds = new Set(workspaces.map((row) => row.id));
60
+ if (sqlite && tableExists(sqlite, "workspace_session_aliases")) {
61
+ const aliases = sqlite.prepare("select alias_id from workspace_session_aliases").all();
62
+ for (const alias of aliases)
63
+ workspaceIds.add(alias.alias_id);
64
+ }
65
+ const protectedReviewWorkspaceIds = reviewProtectedWorkspaceIds(stateDir, workspaceIds);
66
+ const activity = inspectActivity(sqlite, cutoff, now);
67
+ const bash = inspectBash(sqlite, cutoff, now);
68
+ const hostTurns = inspectHostTurns(sqlite, cutoff, now);
69
+ const workspaceState = inspectWorkspaceState(sqlite, workspaces);
70
+ const privateState = inspectPrivateWorkspaceState(stateDir, workspaceIds, now, policy);
71
+ const reviewRefs = inspectReviewRefs(workspaces, protectedReviewWorkspaceIds, policy);
72
+ return {
73
+ stateDir,
74
+ database: sqlite ? "present" : "absent",
75
+ policy: {
76
+ ...policy,
77
+ durableHistory: policy.historyDays === null ? "unlimited" : `${policy.historyDays} days`,
78
+ namedCheckpoints: "protected-explicit-delete-only",
79
+ },
80
+ activityAudit: activity,
81
+ durableBashOutput: bash,
82
+ hostTurns,
83
+ workspaceState,
84
+ namedCheckpoints: privateState.checkpoints,
85
+ workspaceTasks: privateState.tasks,
86
+ reviewRefs,
87
+ administrativeState: privateState.administrative,
88
+ };
89
+ }
90
+ finally {
91
+ databaseSnapshot?.close();
92
+ }
93
+ }
94
+ export function resolveMaintenanceRetentionPolicy(config, env = process.env) {
95
+ const configuredDays = env.FORGERELAY_RETENTION_HISTORY_DAYS ?? config?.historyDays;
96
+ const historyDays = configuredDays === undefined || configuredDays === null || configuredDays === ""
97
+ ? null
98
+ : parseRetentionDays(configuredDays);
99
+ const configuredOrphans = env.FORGERELAY_RETENTION_ORPHANED_ADMIN ?? config?.orphanedAdministrativeState;
100
+ return {
101
+ historyDays,
102
+ orphanedAdministrativeState: parseOptionalBoolean(configuredOrphans) ?? false,
103
+ };
104
+ }
105
+ function readMaintenanceConfig(env) {
106
+ const path = forgerelayConfigPath(env);
107
+ try {
108
+ return JSON.parse(readFileSync(path, "utf8"));
109
+ }
110
+ catch (error) {
111
+ if (isErrno(error, "ENOENT"))
112
+ return {};
113
+ throw new Error(`Unable to read ForgeRelay maintenance config ${path}: ${errorMessage(error)}`);
114
+ }
115
+ }
116
+ function resolveMaintenanceStateDir(config, env) {
117
+ const configured = env.FORGERELAY_STATE_DIR ?? config.stateDir ?? join(homedir(), ".local", "share", "forgerelay");
118
+ return resolve(expandHomePath(String(configured)));
119
+ }
120
+ function openReadOnlyDatabaseSnapshot(stateDir) {
121
+ const source = databasePath(stateDir);
122
+ if (!existsSync(source))
123
+ return undefined;
124
+ for (let attempt = 0; attempt < 3; attempt += 1) {
125
+ const before = sqliteSourceFingerprint(source);
126
+ const directory = mkdtempSync(join(tmpdir(), "forgerelay-maintenance-db-"));
127
+ const snapshot = join(directory, "forgerelay.sqlite");
128
+ try {
129
+ copyFileSync(source, snapshot);
130
+ const sourceWal = `${source}-wal`;
131
+ if (existsSync(sourceWal))
132
+ copyFileSync(sourceWal, `${snapshot}-wal`);
133
+ const after = sqliteSourceFingerprint(source);
134
+ if (before !== after) {
135
+ rmSync(directory, { recursive: true, force: true });
136
+ continue;
137
+ }
138
+ const sqlite = new Database(snapshot, { readonly: true, fileMustExist: true });
139
+ sqlite.pragma("query_only = ON");
140
+ return {
141
+ sqlite,
142
+ close() {
143
+ sqlite.close();
144
+ rmSync(directory, { recursive: true, force: true });
145
+ },
146
+ };
147
+ }
148
+ catch (error) {
149
+ rmSync(directory, { recursive: true, force: true });
150
+ throw error;
151
+ }
152
+ }
153
+ throw new Error("ForgeRelay state changed repeatedly while creating a read-only maintenance snapshot; retry inspection.");
154
+ }
155
+ function sqliteSourceFingerprint(path) {
156
+ return [path, `${path}-wal`].map((candidate) => {
157
+ try {
158
+ const stats = statSync(candidate);
159
+ return `${candidate}:${stats.size}:${stats.mtimeMs}`;
160
+ }
161
+ catch (error) {
162
+ if (isErrno(error, "ENOENT"))
163
+ return `${candidate}:missing`;
164
+ throw error;
165
+ }
166
+ }).join("|");
167
+ }
168
+ function inspectActivity(sqlite, cutoff, now) {
169
+ if (!sqlite || !tableExists(sqlite, "activity_audit_events")) {
170
+ return { ...emptyAge(), events: 0, activities: 0, payloadBytes: 0, reclaimableEvents: 0, reclaimableActivities: 0, reclaimablePayloadBytes: 0 };
171
+ }
172
+ const payload = columnExists(sqlite, "activity_audit_events", "payload_length")
173
+ ? "coalesce(sum(payload_length), 0)"
174
+ : "0";
175
+ const row = sqlite.prepare(`select count(*) as count, count(distinct activity_id) as activities,
176
+ ${payload} as bytes, min(created_at) as oldest, max(created_at) as newest
177
+ from activity_audit_events`).get();
178
+ if (cutoff === null) {
179
+ return {
180
+ ...ages(row.oldest, row.newest, now),
181
+ events: row.count,
182
+ activities: row.activities,
183
+ payloadBytes: row.bytes,
184
+ reclaimableEvents: 0,
185
+ reclaimableActivities: 0,
186
+ reclaimablePayloadBytes: 0,
187
+ };
188
+ }
189
+ const eligible = eligibleActivityCte(sqlite);
190
+ const reclaim = sqlite.prepare(`${eligible}
191
+ select count(*) as count, count(distinct activity_id) as activities,
192
+ ${payload} as bytes
193
+ from activity_audit_events
194
+ where activity_id in (select activity_id from eligible_activities)`).get(cutoff);
195
+ return {
196
+ ...ages(row.oldest, row.newest, now),
197
+ events: row.count,
198
+ activities: row.activities,
199
+ payloadBytes: row.bytes,
200
+ reclaimableEvents: reclaim.count,
201
+ reclaimableActivities: reclaim.activities,
202
+ reclaimablePayloadBytes: reclaim.bytes,
203
+ };
204
+ }
205
+ function inspectBash(sqlite, cutoff, now) {
206
+ if (!sqlite || !tableExists(sqlite, "bash_output_streams")) {
207
+ return { ...emptyAge(), streams: 0, runningStreams: 0, payloadBytes: 0, reclaimableStreams: 0, reclaimablePayloadBytes: 0 };
208
+ }
209
+ const bytesExpression = bashBytesExpression(sqlite);
210
+ const row = sqlite.prepare(`select count(*) as count,
211
+ coalesce(sum(case when status = 'running' then 1 else 0 end), 0) as running,
212
+ coalesce(sum(${bytesExpression}), 0) as bytes,
213
+ min(started_at) as oldest, max(started_at) as newest
214
+ from bash_output_streams`).get();
215
+ if (cutoff === null || !tableExists(sqlite, "activity_audit_events")) {
216
+ return {
217
+ ...ages(row.oldest, row.newest, now),
218
+ streams: row.count,
219
+ runningStreams: row.running,
220
+ payloadBytes: row.bytes,
221
+ reclaimableStreams: 0,
222
+ reclaimablePayloadBytes: 0,
223
+ };
224
+ }
225
+ const eligible = eligibleActivityCte(sqlite);
226
+ const reclaim = sqlite.prepare(`${eligible}
227
+ select count(*) as count, coalesce(sum(${bytesExpression}), 0) as bytes
228
+ from bash_output_streams
229
+ where status <> 'running'
230
+ and activity_id in (select activity_id from eligible_activities)`).get(cutoff);
231
+ return {
232
+ ...ages(row.oldest, row.newest, now),
233
+ streams: row.count,
234
+ runningStreams: row.running,
235
+ payloadBytes: row.bytes,
236
+ reclaimableStreams: reclaim.count,
237
+ reclaimablePayloadBytes: reclaim.bytes,
238
+ };
239
+ }
240
+ function inspectHostTurns(sqlite, cutoff, now) {
241
+ if (!sqlite || !tableExists(sqlite, "activity_host_turns")) {
242
+ return { ...emptyAge(), turns: 0, reclaimableTurns: 0 };
243
+ }
244
+ const row = sqlite.prepare("select count(*) as count, min(created_at) as oldest, max(created_at) as newest from activity_host_turns").get();
245
+ let reclaimableTurns = 0;
246
+ if (cutoff !== null) {
247
+ if (tableExists(sqlite, "activity_audit_events")) {
248
+ const eligible = eligibleActivityCte(sqlite);
249
+ reclaimableTurns = Number(sqlite.prepare(`${eligible}
250
+ select count(*) as count from eligible_turns`).get(cutoff).count);
251
+ }
252
+ else {
253
+ reclaimableTurns = Number(sqlite.prepare("select count(*) as count from activity_host_turns where created_at < ?").get(cutoff).count);
254
+ }
255
+ }
256
+ return { ...ages(row.oldest, row.newest, now), turns: row.count, reclaimableTurns };
257
+ }
258
+ function inspectWorkspaceState(sqlite, workspaces) {
259
+ let missingManagedBacking = 0;
260
+ let recoverableManagedBackingCandidates = 0;
261
+ let manualInterventionManagedBackingCandidates = 0;
262
+ for (const workspace of workspaces) {
263
+ if (workspace.managed !== "true" || workspace.mode !== "worktree" || workspace.status !== "active")
264
+ continue;
265
+ if (existsSync(workspace.root))
266
+ continue;
267
+ missingManagedBacking += 1;
268
+ const source = workspace.source_root;
269
+ const branch = workspace.branch;
270
+ if (source && branch && existsSync(source) && gitRefExists(source, `refs/heads/${branch}`)) {
271
+ recoverableManagedBackingCandidates += 1;
272
+ }
273
+ else {
274
+ manualInterventionManagedBackingCandidates += 1;
275
+ }
276
+ }
277
+ return {
278
+ sessions: workspaces.length,
279
+ activeSessions: workspaces.filter((row) => row.status === "active").length,
280
+ closedSessions: workspaces.filter((row) => row.status !== "active").length,
281
+ managedSessions: workspaces.filter((row) => row.managed === "true").length,
282
+ missingManagedBacking,
283
+ recoverableManagedBackingCandidates,
284
+ manualInterventionManagedBackingCandidates,
285
+ conversationBindings: tableCount(sqlite, "workspace_conversation_bindings"),
286
+ contextDeliveries: tableCount(sqlite, "workspace_context_deliveries"),
287
+ loadedInstructionFiles: tableCount(sqlite, "loaded_agent_files"),
288
+ };
289
+ }
290
+ function inspectPrivateWorkspaceState(stateDir, workspaceIds, now, policy) {
291
+ const workspacesDir = join(stateDir, "workspaces");
292
+ let entries;
293
+ try {
294
+ entries = readdirSync(workspacesDir, { withFileTypes: true });
295
+ }
296
+ catch (error) {
297
+ if (isErrno(error, "ENOENT")) {
298
+ return {
299
+ checkpoints: { ...emptyAge(), workspaces: 0, checkpoints: 0, stateBytes: 0, invalidStateFiles: 0, protected: true, reclaimableCheckpoints: 0 },
300
+ tasks: { workspaces: 0, lists: 0, tasks: 0, unfinishedTasks: 0, stateBytes: 0, invalidStateFiles: 0, protected: true },
301
+ administrative: { orphanWorkspaceStateDirectories: 0, protectedOrphanWorkspaceStateDirectories: 0, reclaimableOrphanWorkspaceStateDirectories: 0 },
302
+ };
303
+ }
304
+ throw error;
305
+ }
306
+ let checkpointWorkspaces = 0;
307
+ let checkpoints = 0;
308
+ let checkpointBytes = 0;
309
+ let invalidCheckpointFiles = 0;
310
+ let checkpointOldest = null;
311
+ let checkpointNewest = null;
312
+ let taskWorkspaces = 0;
313
+ let taskLists = 0;
314
+ let tasks = 0;
315
+ let unfinishedTasks = 0;
316
+ let taskBytes = 0;
317
+ let invalidTaskFiles = 0;
318
+ let orphanDirs = 0;
319
+ let protectedOrphanDirs = 0;
320
+ let reclaimableOrphanDirs = 0;
321
+ for (const entry of entries) {
322
+ if (!entry.isDirectory())
323
+ continue;
324
+ const workspaceDir = join(workspacesDir, entry.name);
325
+ const checkpointPath = join(workspaceDir, "checkpoints.json");
326
+ const taskPath = join(workspaceDir, "tasks.json");
327
+ const hasCheckpointState = existsSync(checkpointPath);
328
+ const hasTaskState = existsSync(taskPath);
329
+ const hasPrivateState = readdirSync(workspaceDir).length > 0;
330
+ if (hasCheckpointState) {
331
+ checkpointWorkspaces += 1;
332
+ const inspected = readBoundedJson(checkpointPath, MAX_CHECKPOINT_STATE_BYTES);
333
+ checkpointBytes += inspected.bytes;
334
+ if (!inspected.value || !Array.isArray(inspected.value.checkpoints)) {
335
+ invalidCheckpointFiles += 1;
336
+ }
337
+ else {
338
+ const values = inspected.value.checkpoints;
339
+ checkpoints += values.length;
340
+ for (const checkpoint of values) {
341
+ const createdAt = typeof checkpoint.createdAt === "string" ? checkpoint.createdAt : null;
342
+ if (!createdAt)
343
+ continue;
344
+ checkpointOldest = earlier(checkpointOldest, createdAt);
345
+ checkpointNewest = later(checkpointNewest, createdAt);
346
+ }
347
+ }
348
+ }
349
+ if (hasTaskState) {
350
+ taskWorkspaces += 1;
351
+ const inspected = readBoundedJson(taskPath, MAX_TASK_STATE_BYTES);
352
+ taskBytes += inspected.bytes;
353
+ const lists = inspected.value && Array.isArray(inspected.value.lists)
354
+ ? inspected.value.lists
355
+ : undefined;
356
+ if (!lists) {
357
+ invalidTaskFiles += 1;
358
+ }
359
+ else {
360
+ taskLists += lists.length;
361
+ for (const list of lists) {
362
+ const listTasks = Array.isArray(list.tasks) ? list.tasks : [];
363
+ tasks += listTasks.length;
364
+ unfinishedTasks += listTasks.filter((task) => task.status !== "completed").length;
365
+ }
366
+ }
367
+ }
368
+ if (WORKSPACE_ID.test(entry.name) && !workspaceIds.has(entry.name)) {
369
+ orphanDirs += 1;
370
+ if (hasPrivateState) {
371
+ protectedOrphanDirs += 1;
372
+ }
373
+ else if (policy.orphanedAdministrativeState) {
374
+ reclaimableOrphanDirs += 1;
375
+ }
376
+ }
377
+ }
378
+ return {
379
+ checkpoints: {
380
+ ...ages(checkpointOldest, checkpointNewest, now),
381
+ workspaces: checkpointWorkspaces,
382
+ checkpoints,
383
+ stateBytes: checkpointBytes,
384
+ invalidStateFiles: invalidCheckpointFiles,
385
+ protected: true,
386
+ reclaimableCheckpoints: 0,
387
+ },
388
+ tasks: {
389
+ workspaces: taskWorkspaces,
390
+ lists: taskLists,
391
+ tasks,
392
+ unfinishedTasks,
393
+ stateBytes: taskBytes,
394
+ invalidStateFiles: invalidTaskFiles,
395
+ protected: true,
396
+ },
397
+ administrative: {
398
+ orphanWorkspaceStateDirectories: orphanDirs,
399
+ protectedOrphanWorkspaceStateDirectories: protectedOrphanDirs,
400
+ reclaimableOrphanWorkspaceStateDirectories: reclaimableOrphanDirs,
401
+ },
402
+ };
403
+ }
404
+ function reviewProtectedWorkspaceIds(stateDir, workspaceIds) {
405
+ const protectedIds = new Set(workspaceIds);
406
+ const workspacesDir = join(stateDir, "workspaces");
407
+ let entries;
408
+ try {
409
+ entries = readdirSync(workspacesDir, { withFileTypes: true });
410
+ }
411
+ catch (error) {
412
+ if (isErrno(error, "ENOENT"))
413
+ return protectedIds;
414
+ throw error;
415
+ }
416
+ for (const entry of entries) {
417
+ if (!entry.isDirectory() || !WORKSPACE_ID.test(entry.name))
418
+ continue;
419
+ if (readdirSync(join(workspacesDir, entry.name)).length > 0)
420
+ protectedIds.add(entry.name);
421
+ }
422
+ return protectedIds;
423
+ }
424
+ function inspectReviewRefs(workspaces, workspaceIds, policy) {
425
+ const roots = new Set();
426
+ for (const workspace of workspaces) {
427
+ const candidate = workspace.source_root ?? workspace.root;
428
+ if (candidate && existsSync(candidate))
429
+ roots.add(candidate);
430
+ }
431
+ const repositories = new Set();
432
+ const refs = new Set();
433
+ let unavailableRepositories = 0;
434
+ for (const root of roots) {
435
+ const gitRoot = gitOutput(root, ["rev-parse", "--show-toplevel"]);
436
+ if (!gitRoot) {
437
+ unavailableRepositories += 1;
438
+ continue;
439
+ }
440
+ if (repositories.has(gitRoot))
441
+ continue;
442
+ repositories.add(gitRoot);
443
+ const listed = gitOutput(gitRoot, ["for-each-ref", "--format=%(refname)", "refs/forgerelay/review"]);
444
+ if (listed === undefined) {
445
+ unavailableRepositories += 1;
446
+ continue;
447
+ }
448
+ for (const ref of listed.split(/\r?\n/).map((value) => value.trim()).filter(Boolean)) {
449
+ refs.add(`${gitRoot}\0${ref}`);
450
+ }
451
+ }
452
+ let orphanedRefs = 0;
453
+ for (const key of refs) {
454
+ const ref = key.slice(key.indexOf("\0") + 1);
455
+ const match = REVIEW_REF.exec(ref);
456
+ if (match && !workspaceIds.has(match[1]))
457
+ orphanedRefs += 1;
458
+ }
459
+ return {
460
+ repositories: repositories.size,
461
+ refs: refs.size,
462
+ orphanedRefs,
463
+ reclaimableRefs: policy.orphanedAdministrativeState ? orphanedRefs : 0,
464
+ unavailableRepositories,
465
+ };
466
+ }
467
+ function tableCount(sqlite, table) {
468
+ if (!sqlite || !tableExists(sqlite, table))
469
+ return 0;
470
+ return Number(sqlite.prepare(`select count(*) as count from ${table}`).get().count);
471
+ }
472
+ function readBoundedJson(path, maxBytes) {
473
+ try {
474
+ const size = statSync(path).size;
475
+ if (size > maxBytes)
476
+ return { bytes: size };
477
+ return { value: JSON.parse(readFileSync(path, "utf8")), bytes: size };
478
+ }
479
+ catch {
480
+ return { bytes: safeFileSize(path) };
481
+ }
482
+ }
483
+ function gitRefExists(root, ref) {
484
+ const result = spawnSync("git", ["-C", root, "show-ref", "--verify", "--quiet", ref], {
485
+ encoding: "utf8",
486
+ windowsHide: true,
487
+ shell: false,
488
+ });
489
+ return result.status === 0;
490
+ }
491
+ function gitOutput(root, args) {
492
+ const result = spawnSync("git", ["-C", root, ...args], {
493
+ encoding: "utf8",
494
+ windowsHide: true,
495
+ shell: false,
496
+ });
497
+ if (result.error || result.status !== 0)
498
+ return undefined;
499
+ return result.stdout.trim();
500
+ }
501
+ function ages(oldestAt, newestAt, now) {
502
+ return {
503
+ oldestAt,
504
+ newestAt,
505
+ oldestAgeDays: ageDays(oldestAt, now),
506
+ newestAgeDays: ageDays(newestAt, now),
507
+ };
508
+ }
509
+ function emptyAge() {
510
+ return { oldestAt: null, newestAt: null, oldestAgeDays: null, newestAgeDays: null };
511
+ }
512
+ function ageDays(value, now) {
513
+ if (!value)
514
+ return null;
515
+ const timestamp = Date.parse(value);
516
+ if (!Number.isFinite(timestamp))
517
+ return null;
518
+ return Math.max(0, Math.floor((now.getTime() - timestamp) / DAY_MS));
519
+ }
520
+ function earlier(current, candidate) {
521
+ return current === null || candidate < current ? candidate : current;
522
+ }
523
+ function later(current, candidate) {
524
+ return current === null || candidate > current ? candidate : current;
525
+ }
526
+ function safeFileSize(path) {
527
+ try {
528
+ return statSync(path).size;
529
+ }
530
+ catch {
531
+ return 0;
532
+ }
533
+ }
534
+ function parseRetentionDays(value) {
535
+ const parsed = typeof value === "number" ? value : Number(String(value));
536
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_RETENTION_DAYS) {
537
+ throw new Error(`Retention historyDays must be an integer between 1 and ${MAX_RETENTION_DAYS}.`);
538
+ }
539
+ return parsed;
540
+ }
541
+ function parseOptionalBoolean(value) {
542
+ if (value === undefined || value === null || value === "")
543
+ return undefined;
544
+ if (typeof value === "boolean")
545
+ return value;
546
+ const normalized = String(value).trim().toLowerCase();
547
+ if (["1", "true", "yes", "on"].includes(normalized))
548
+ return true;
549
+ if (["0", "false", "no", "off"].includes(normalized))
550
+ return false;
551
+ throw new Error("Retention orphanedAdministrativeState must be a boolean.");
552
+ }
553
+ function isErrno(error, code) {
554
+ return error instanceof Error && "code" in error && error.code === code;
555
+ }
556
+ function errorMessage(error) {
557
+ return error instanceof Error ? error.message : String(error);
558
+ }
559
+ function printMaintenanceReport(report) {
560
+ console.log(`ForgeRelay maintenance inspection`);
561
+ console.log(`State directory: ${report.stateDir}`);
562
+ console.log(`Database: ${report.database}`);
563
+ console.log(`Retention: durable history ${report.policy.durableHistory}; orphaned administrative cleanup ${report.policy.orphanedAdministrativeState ? "enabled" : "disabled"}`);
564
+ console.log(`Activity/Audit: ${report.activityAudit.activities} activities / ${report.activityAudit.events} events / ${formatBytes(report.activityAudit.payloadBytes)} payload; reclaimable ${report.activityAudit.reclaimableActivities} activities / ${formatBytes(report.activityAudit.reclaimablePayloadBytes)}`);
565
+ console.log(`Durable Bash: ${report.durableBashOutput.streams} streams (${report.durableBashOutput.runningStreams} running) / ${formatBytes(report.durableBashOutput.payloadBytes)}; reclaimable ${report.durableBashOutput.reclaimableStreams} / ${formatBytes(report.durableBashOutput.reclaimablePayloadBytes)}`);
566
+ console.log(`Host Turns: ${report.hostTurns.turns}; reclaimable ${report.hostTurns.reclaimableTurns}`);
567
+ console.log(`Workspace state: ${report.workspaceState.sessions} sessions, ${report.workspaceState.conversationBindings} bindings, ${report.workspaceState.contextDeliveries} context deliveries; missing managed backing ${report.workspaceState.missingManagedBacking}`);
568
+ console.log(`Named checkpoints: ${report.namedCheckpoints.checkpoints} across ${report.namedCheckpoints.workspaces} Workspaces — protected, explicit deletion only`);
569
+ console.log(`Workspace Tasks: ${report.workspaceTasks.tasks} tasks across ${report.workspaceTasks.workspaces} Workspaces — protected`);
570
+ console.log(`Review refs: ${report.reviewRefs.refs} across ${report.reviewRefs.repositories} repositories; orphaned ${report.reviewRefs.orphanedRefs}; reclaimable ${report.reviewRefs.reclaimableRefs}`);
571
+ console.log(`Administrative state: ${report.administrativeState.orphanWorkspaceStateDirectories} orphan Workspace directories; reclaimable ${report.administrativeState.reclaimableOrphanWorkspaceStateDirectories}`);
572
+ console.log(`Age range: activity ${formatAge(report.activityAudit)}; bash ${formatAge(report.durableBashOutput)}; turns ${formatAge(report.hostTurns)}; checkpoints ${formatAge(report.namedCheckpoints)}`);
573
+ }
574
+ function formatAge(value) {
575
+ if (value.oldestAgeDays === null)
576
+ return "none";
577
+ return `${value.oldestAgeDays}d oldest / ${value.newestAgeDays ?? value.oldestAgeDays}d newest`;
578
+ }
579
+ function formatBytes(bytes) {
580
+ if (bytes < 1024)
581
+ return `${bytes} B`;
582
+ if (bytes < 1024 * 1024)
583
+ return `${(bytes / 1024).toFixed(1)} KiB`;
584
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
585
+ }
586
+ function printMaintenanceHelp() {
587
+ console.log([
588
+ "ForgeRelay maintenance",
589
+ "",
590
+ "Usage:",
591
+ " forgerelay maintenance inspect [--json]",
592
+ " forgerelay maintenance prune [--json]",
593
+ "",
594
+ "Inspection is read-only. Durable history is retained without an age limit unless retention.historyDays is explicitly configured.",
595
+ "Prune is manual owner maintenance and removes only categories authorized by the configured retention policy.",
596
+ "Named Workspace checkpoints and Workspace Tasks are protected from retention pruning.",
597
+ ].join("\n"));
598
+ }