@akira-tl/forgerelay 0.9.2 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +9 -3
- package/capabilities/workspace/workspace-checkpoints/GUIDE.md +35 -4
- package/dist/cli/init.js +39 -23
- package/dist/cli/maintenance-prune.js +479 -0
- package/dist/cli/maintenance-retention.js +93 -0
- package/dist/cli/maintenance.js +598 -0
- package/dist/cli/setup-support.js +21 -0
- package/dist/cli.js +29 -6
- package/dist/mcp/oauth/public-url.js +3 -0
- package/dist/mcp/oauth/router.js +14 -9
- package/dist/mcp/server/core/capabilities/workspace-checkpoint.js +9 -0
- package/dist/mcp/server/core/capability-registry.js +1 -1
- package/dist/mcp/server/transport/http-server.js +13 -8
- package/dist/runtime/config/config.js +54 -6
- package/dist/runtime/state/runtime-lease.js +109 -0
- package/dist/server.js +6 -0
- package/dist/workspaces/state/workspace-checkpoints.js +129 -13
- package/docs/configuration.md +100 -13
- package/package.json +2 -2
- package/scripts/ci/architecture.mjs +9 -2
- package/scripts/release/release-gate.test.mjs +6 -0
|
@@ -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
|
+
}
|
|
@@ -34,6 +34,9 @@ export function normalizePublicBaseUrl(value) {
|
|
|
34
34
|
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
35
35
|
return parsed.toString().replace(/\/$/, "");
|
|
36
36
|
}
|
|
37
|
+
export function setupBindAddress(mode) {
|
|
38
|
+
return mode === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
39
|
+
}
|
|
37
40
|
export function classifyClientFacingBaseUrl(value) {
|
|
38
41
|
const parsed = new URL(normalizePublicBaseUrl(value));
|
|
39
42
|
if (parsed.protocol === "https:")
|
|
@@ -62,6 +65,24 @@ export function validateClientFacingBaseUrls(value) {
|
|
|
62
65
|
export function hasInsecureLanBaseUrl(baseUrls) {
|
|
63
66
|
return baseUrls.some((baseUrl) => classifyClientFacingBaseUrl(baseUrl) === "insecure-lan");
|
|
64
67
|
}
|
|
68
|
+
export function validateLanClientFacingBaseUrls(value) {
|
|
69
|
+
const validation = validateClientFacingBaseUrls(value);
|
|
70
|
+
if (validation)
|
|
71
|
+
return validation;
|
|
72
|
+
const baseUrls = normalizePublicBaseUrlsInput(value ?? "");
|
|
73
|
+
return baseUrls.every((baseUrl) => new URL(baseUrl).protocol === "http:")
|
|
74
|
+
? undefined
|
|
75
|
+
: "Direct LAN mode requires http:// client-facing URLs on a trusted private network.";
|
|
76
|
+
}
|
|
77
|
+
export function validateHttpsProxyBaseUrls(value) {
|
|
78
|
+
const validation = validateClientFacingBaseUrls(value);
|
|
79
|
+
if (validation)
|
|
80
|
+
return validation;
|
|
81
|
+
const baseUrls = normalizePublicBaseUrlsInput(value ?? "");
|
|
82
|
+
return baseUrls.every((baseUrl) => new URL(baseUrl).protocol === "https:")
|
|
83
|
+
? undefined
|
|
84
|
+
: "HTTPS proxy mode requires HTTPS client-facing URLs.";
|
|
85
|
+
}
|
|
65
86
|
function isPrivateNetworkHost(hostname) {
|
|
66
87
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
67
88
|
if (host === "localhost" || !host.includes(".") || host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home.arpa")) {
|