@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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.4] - 2026-09-04
8
+
9
+ ### Added
10
+
11
+ - Added owner-facing `forgerelay maintenance inspect` and `forgerelay maintenance prune` for the 0.9.4 retention contract. Durable Activity/Audit, Host Turn, and Bash history remains unlimited by default; configured retention is explicit prune authorization, whole Host Turn cohorts protect recent/nonterminal/running Bash/active Subagent work, and persistent Workspace identity, aliases, Task Lists, named checkpoints, managed worktrees/branches, and non-empty private Workspace state remain protected.
12
+ - Added a runtime state lease so `serve` and destructive maintenance cannot operate on the same state directory concurrently. Retained Activity payloads sharing segments with pruned history are compacted before unreferenced old segments are removed, and repeat prune is idempotent.
13
+
14
+ ### Fixed
15
+
16
+ - Made routed `publicBaseUrl` path prefixes real inbound MCP/OAuth/health/App deployment boundaries, split setup network modes into safe loopback proxy versus direct LAN binds, and replaced permissive proxy trust with explicit trusted proxy sources. This fixes reverse-proxy `X-Forwarded-For` failures without enabling spoofable global `trust proxy` behavior.
17
+
7
18
  ## [0.9.3] - 2026-09-04
8
19
 
9
20
  ### Added
package/README.md CHANGED
@@ -55,8 +55,10 @@ http://127.0.0.1:7676/mcp
55
55
 
56
56
  If the MCP host cannot reach localhost, put ForgeRelay behind a public HTTPS
57
57
  tunnel or reverse proxy such as Cloudflare Tunnel, ngrok, Pinggy, Tailscale
58
- Funnel, or your own proxy. During setup, enter the public base URL before the
59
- final `/mcp`; routed prefixes are allowed:
58
+ Funnel, or your own proxy. Setup separates **Direct LAN** (`0.0.0.0`) from
59
+ **HTTPS reverse proxy / tunnel** (`127.0.0.1` with loopback-only proxy trust), so
60
+ you do not need to choose a bind address manually. During setup, enter the public
61
+ base URL before the final `/mcp`; routed prefixes are allowed:
60
62
 
61
63
  ```text
62
64
  https://your-tunnel-host.example.com/forgerelay/main
@@ -69,7 +71,11 @@ https://your-tunnel-host.example.com/forgerelay/main/mcp
69
71
  ```
70
72
 
71
73
  `publicBaseUrl` may also be an ordered list when multiple public entries are
72
- valid; each entry may use its own route and the first is canonical.
74
+ valid; each entry may use its own route and the first is canonical for generated
75
+ metadata/links. Every configured pathname is an accepted inbound operational route
76
+ boundary. If the only configured route is `/forgerelay/main`, MCP/OAuth/health/App
77
+ routes are served below that prefix rather than in parallel at naked `/mcp`,
78
+ `/authorize`, or `/healthz` paths.
73
79
 
74
80
  ForgeRelay uses an Owner-password OAuth approval flow. `forgerelay init` prints
75
81
  the password and stores it in the active config directory. New installations use:
package/dist/cli/init.js CHANGED
@@ -4,7 +4,7 @@ import { publicEndpointUrl } from "../mcp/oauth/public-url.js";
4
4
  import { expandHomePath } from "../mcp/filesystem/roots.js";
5
5
  import { installManagedLanguageServers, installedManagedLanguageServers, managedLanguageServerOptions, } from "../lsp/runtime/managed-language-servers.js";
6
6
  import { generateInstanceId, generateOwnerToken, loadForgeRelayFiles, resolveSubagentsFlag, writeForgeRelayAuth, writeForgeRelayConfig, } from "../runtime/config/user-config.js";
7
- import { classifyClientFacingBaseUrl, compactPublicBaseUrlConfig, hasInsecureLanBaseUrl, isLoopbackBindAddress, normalizePublicBaseUrlsInput, SetupCancelledError, textPrompt, validateBindAddress, validateClientFacingBaseUrls, validatePort, } from "./setup-support.js";
7
+ import { classifyClientFacingBaseUrl, compactPublicBaseUrlConfig, hasInsecureLanBaseUrl, isLoopbackBindAddress, normalizePublicBaseUrlsInput, setupBindAddress, SetupCancelledError, textPrompt, validateHttpsProxyBaseUrls, validateLanClientFacingBaseUrls, validatePort, } from "./setup-support.js";
8
8
  export async function runInit({ force }) {
9
9
  const files = loadForgeRelayFiles();
10
10
  if (!force && files.configExists && files.authExists) {
@@ -36,9 +36,11 @@ export async function runInit({ force }) {
36
36
  const existingPublicBaseUrls = Array.isArray(files.config.publicBaseUrl)
37
37
  ? files.config.publicBaseUrl
38
38
  : files.config.publicBaseUrl ? [files.config.publicBaseUrl] : [];
39
- const defaultNetworkMode = !isLoopbackBindAddress(files.config.host ?? "127.0.0.1") || existingPublicBaseUrls.length > 0
40
- ? "network"
41
- : "local";
39
+ const defaultNetworkMode = !isLoopbackBindAddress(files.config.host ?? "127.0.0.1")
40
+ ? "lan"
41
+ : existingPublicBaseUrls.some((baseUrl) => new URL(baseUrl).protocol === "https:")
42
+ ? "proxy"
43
+ : "local";
42
44
  const selectedMode = await prompts.select({
43
45
  message: "How should clients reach this ForgeRelay instance?",
44
46
  initialValue: defaultNetworkMode,
@@ -54,40 +56,53 @@ export async function runInit({ force }) {
54
56
  hint: "Bind to loopback; another ForgeRelay reaches it through an SSH tunnel.",
55
57
  },
56
58
  {
57
- value: "network",
58
- label: "LAN / HTTPS proxy",
59
- hint: "Expose through a LAN address or an HTTPS reverse proxy/tunnel.",
59
+ value: "lan",
60
+ label: "Direct LAN",
61
+ hint: "Bind to 0.0.0.0; clients connect directly over a trusted private LAN.",
62
+ },
63
+ {
64
+ value: "proxy",
65
+ label: "HTTPS reverse proxy / tunnel",
66
+ hint: "Bind to 127.0.0.1; a local trusted proxy publishes the HTTPS endpoint.",
60
67
  },
61
68
  ],
62
69
  });
63
70
  if (prompts.isCancel(selectedMode))
64
71
  throw new SetupCancelledError();
65
72
  const networkMode = selectedMode;
66
- let host = "127.0.0.1";
73
+ const host = setupBindAddress(networkMode);
67
74
  let publicBaseUrl = null;
68
75
  let clientFacingBaseUrls = [`http://127.0.0.1:${port}`];
69
- if (networkMode === "network") {
70
- const defaultHost = files.config.host && !isLoopbackBindAddress(files.config.host)
71
- ? files.config.host
72
- : "0.0.0.0";
73
- host = await textPrompt({
74
- message: "Which address should ForgeRelay bind to? Use 0.0.0.0 for direct LAN, or 127.0.0.1 behind a local reverse proxy.",
75
- placeholder: defaultHost,
76
- defaultValue: defaultHost,
77
- validate: validateBindAddress,
78
- });
79
- const defaultClientFacing = existingPublicBaseUrls.join(", ");
76
+ const trustedProxies = networkMode === "proxy" ? ["loopback"] : undefined;
77
+ if (networkMode === "lan" || networkMode === "proxy") {
78
+ const validateBaseUrls = networkMode === "lan"
79
+ ? validateLanClientFacingBaseUrls
80
+ : validateHttpsProxyBaseUrls;
81
+ const existingClientFacing = existingPublicBaseUrls.join(", ");
82
+ const defaultClientFacing = existingClientFacing && validateBaseUrls(existingClientFacing) === undefined
83
+ ? existingClientFacing
84
+ : "";
85
+ if (networkMode === "proxy") {
86
+ prompts.note([
87
+ "The public URL may include a path prefix, for example https://example.com/forgerelay/debug.",
88
+ "That prefix is the deployment route boundary, so MCP, OAuth, health, and App assets are served below it.",
89
+ ].join("\n"), "Routed public URL");
90
+ }
80
91
  clientFacingBaseUrls = normalizePublicBaseUrlsInput(await textPrompt({
81
92
  message: defaultClientFacing
82
93
  ? `What client-facing base URLs should clients use? Press Enter to keep ${defaultClientFacing}`
83
- : "What client-facing base URL should clients use?",
84
- placeholder: defaultClientFacing || `http://192.168.1.20:${port} or https://forge.example.com`,
94
+ : networkMode === "lan"
95
+ ? "What direct LAN base URL should clients use?"
96
+ : "What HTTPS public base URL should clients use?",
97
+ placeholder: defaultClientFacing || (networkMode === "lan"
98
+ ? `http://192.168.1.20:${port}`
99
+ : "https://example.com/forgerelay/debug"),
85
100
  defaultValue: defaultClientFacing,
86
- validate: validateClientFacingBaseUrls,
101
+ validate: validateBaseUrls,
87
102
  }));
88
103
  for (const baseUrl of clientFacingBaseUrls)
89
104
  classifyClientFacingBaseUrl(baseUrl);
90
- if (hasInsecureLanBaseUrl(clientFacingBaseUrls)) {
105
+ if (networkMode === "lan" && hasInsecureLanBaseUrl(clientFacingBaseUrls)) {
91
106
  prompts.note([
92
107
  "Plain HTTP does not encrypt the ForgeRelay Owner approval flow or MCP bearer tokens.",
93
108
  "Use this only on a trusted private LAN. Prefer SSH relay or HTTPS when the network is not fully trusted.",
@@ -140,6 +155,7 @@ export async function runInit({ force }) {
140
155
  allowedRoots,
141
156
  publicBaseUrl,
142
157
  allowedHosts: files.config.allowedHosts,
158
+ trustedProxies,
143
159
  workflowInstructions: files.config.workflowInstructions,
144
160
  appendInstructions: files.config.appendInstructions,
145
161
  subagents: resolveSubagentsFlag(files.config),
@@ -0,0 +1,479 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readdirSync, rmdirSync, rmSync, } from "node:fs";
4
+ import { basename, dirname, join } from "node:path";
5
+ import Database from "better-sqlite3";
6
+ import { SegmentedLogStore } from "../activity/storage/segmented-log.js";
7
+ import { resolveStateRelativePath } from "../activity/storage/paths.js";
8
+ import { databasePath } from "../runtime/state/db/client.js";
9
+ import { acquireRuntimeLease } from "../runtime/state/runtime-lease.js";
10
+ import { bashBytesExpression, columnExists, eligibleActivityCte, tableExists, } from "./maintenance-retention.js";
11
+ const DAY_MS = 24 * 60 * 60 * 1_000;
12
+ const WORKSPACE_ID = /^(?:ws|rws|cws)_[a-z0-9]+$/i;
13
+ const REVIEW_REF = /^refs\/forgerelay\/review\/([^/]+)\/(?:open|baseline)$/;
14
+ export function pruneMaintenanceState(stateDir, policy, now = new Date()) {
15
+ const cutoff = policy.historyDays === null
16
+ ? null
17
+ : new Date(now.getTime() - policy.historyDays * DAY_MS).toISOString();
18
+ const historicalAuthorized = cutoff !== null;
19
+ const administrativeAuthorized = policy.orphanedAdministrativeState;
20
+ const emptyRemoved = {
21
+ activities: 0,
22
+ activityEvents: 0,
23
+ activityPayloadBytes: 0,
24
+ bashStreams: 0,
25
+ bashPayloadBytes: 0,
26
+ hostTurns: 0,
27
+ reviewRefs: 0,
28
+ orphanWorkspaceStateDirectories: 0,
29
+ };
30
+ const emptyCleanup = { removedSegmentFiles: 0, retainedActivityPayloadsRewritten: 0 };
31
+ if (!historicalAuthorized && !administrativeAuthorized) {
32
+ return {
33
+ stateDir,
34
+ cutoff,
35
+ historicalAuthorized,
36
+ administrativeAuthorized,
37
+ protected: emptyProtectedCounts(),
38
+ removed: emptyRemoved,
39
+ cleanup: emptyCleanup,
40
+ result: "noop",
41
+ };
42
+ }
43
+ if (!existsSync(stateDir)) {
44
+ return {
45
+ stateDir,
46
+ cutoff,
47
+ historicalAuthorized,
48
+ administrativeAuthorized,
49
+ protected: emptyProtectedCounts(),
50
+ removed: emptyRemoved,
51
+ cleanup: emptyCleanup,
52
+ result: "noop",
53
+ };
54
+ }
55
+ const lease = acquireRuntimeLease(stateDir);
56
+ try {
57
+ const protectedCounts = readProtectedCounts(stateDir);
58
+ const historical = historicalAuthorized && cutoff
59
+ ? pruneHistoricalState(stateDir, cutoff)
60
+ : emptyHistoricalPruneResult();
61
+ const administrative = administrativeAuthorized
62
+ ? pruneAdministrativeState(stateDir)
63
+ : { reviewRefs: 0, orphanWorkspaceStateDirectories: 0 };
64
+ const removed = {
65
+ activities: historical.activities,
66
+ activityEvents: historical.activityEvents,
67
+ activityPayloadBytes: historical.activityPayloadBytes,
68
+ bashStreams: historical.bashStreams,
69
+ bashPayloadBytes: historical.bashPayloadBytes,
70
+ hostTurns: historical.hostTurns,
71
+ reviewRefs: administrative.reviewRefs,
72
+ orphanWorkspaceStateDirectories: administrative.orphanWorkspaceStateDirectories,
73
+ };
74
+ const removedTotal = Object.values(removed).reduce((total, value) => total + value, 0);
75
+ return {
76
+ stateDir,
77
+ cutoff,
78
+ historicalAuthorized,
79
+ administrativeAuthorized,
80
+ protected: protectedCounts,
81
+ removed,
82
+ cleanup: {
83
+ removedSegmentFiles: historical.removedSegmentFiles,
84
+ retainedActivityPayloadsRewritten: historical.retainedActivityPayloadsRewritten,
85
+ },
86
+ result: removedTotal > 0 ? "pruned" : "noop",
87
+ };
88
+ }
89
+ finally {
90
+ lease.release();
91
+ }
92
+ }
93
+ export function printMaintenancePruneReport(report) {
94
+ console.log("ForgeRelay maintenance prune");
95
+ console.log(`State directory: ${report.stateDir}`);
96
+ console.log(`Historical retention: ${report.historicalAuthorized ? `authorized before ${report.cutoff}` : "not authorized (unlimited)"}`);
97
+ console.log(`Orphan administrative cleanup: ${report.administrativeAuthorized ? "authorized" : "not authorized"}`);
98
+ console.log(`Removed Activity/Audit: ${report.removed.activities} activities / ${report.removed.activityEvents} events / ${formatBytes(report.removed.activityPayloadBytes)} payload`);
99
+ console.log(`Removed durable Bash: ${report.removed.bashStreams} streams / ${formatBytes(report.removed.bashPayloadBytes)}`);
100
+ console.log(`Removed Host Turns: ${report.removed.hostTurns}`);
101
+ console.log(`Removed administrative state: ${report.removed.reviewRefs} review refs / ${report.removed.orphanWorkspaceStateDirectories} orphan Workspace directories`);
102
+ console.log(`Protected active state: ${report.protected.runningBashStreams} running Bash streams / ${report.protected.activeSubagentRuns} active Subagent Runs`);
103
+ console.log("Protected persistent state: Workspace identity, Task Lists, named checkpoints, and managed branches");
104
+ if (report.result === "noop")
105
+ console.log("No authorized eligible state was removed.");
106
+ }
107
+ function pruneHistoricalState(stateDir, cutoff) {
108
+ const path = databasePath(stateDir);
109
+ if (!existsSync(path))
110
+ return emptyHistoricalPruneResult();
111
+ const sqlite = new Database(path, { fileMustExist: true });
112
+ sqlite.pragma("busy_timeout = 5000");
113
+ sqlite.pragma("foreign_keys = ON");
114
+ const newPrefixes = new Set();
115
+ const oldActivityPrefixes = new Set();
116
+ const oldBashPrefixes = new Set();
117
+ let result = emptyHistoricalPruneResult();
118
+ try {
119
+ requireHistoricalSchema(sqlite);
120
+ const logs = new SegmentedLogStore(stateDir);
121
+ const mutate = sqlite.transaction(() => {
122
+ createEligibilityTables(sqlite, cutoff);
123
+ result = historicalCounts(sqlite);
124
+ if (result.activities === 0 && result.bashStreams === 0 && result.hostTurns === 0)
125
+ return;
126
+ const bashRows = sqlite.prepare(`select id, log_file, command_file, error_file
127
+ from bash_output_streams
128
+ where id in (select output_id from maintenance_eligible_bash)`).all();
129
+ for (const row of bashRows) {
130
+ for (const prefix of [row.log_file, row.command_file, row.error_file]) {
131
+ if (prefix)
132
+ oldBashPrefixes.add(prefix);
133
+ }
134
+ }
135
+ const affectedPrefixes = sqlite.prepare(`select distinct payload_file as prefix
136
+ from activity_audit_events
137
+ where activity_id in (select activity_id from maintenance_eligible_activities)
138
+ and payload_file is not null`).all();
139
+ for (const { prefix } of affectedPrefixes) {
140
+ oldActivityPrefixes.add(prefix);
141
+ const retained = sqlite.prepare(`select id, payload_file, payload_offset, payload_length
142
+ from activity_audit_events
143
+ where payload_file = ?
144
+ and payload_offset is not null
145
+ and payload_length is not null
146
+ and activity_id not in (select activity_id from maintenance_eligible_activities)
147
+ order by rowid asc`).all(prefix);
148
+ if (retained.length === 0)
149
+ continue;
150
+ const sourcePrefix = resolveStateRelativePath(stateDir, prefix);
151
+ const compactPrefix = join(dirname(sourcePrefix), `${basename(sourcePrefix)}.retained-${randomBytes(6).toString("hex")}`);
152
+ for (const row of retained) {
153
+ const bytes = logs.read({
154
+ prefix: row.payload_file,
155
+ offset: row.payload_offset,
156
+ length: row.payload_length,
157
+ });
158
+ const next = logs.append(compactPrefix, bytes);
159
+ newPrefixes.add(next.prefix);
160
+ sqlite.prepare(`update activity_audit_events
161
+ set payload_file = ?, payload_offset = ?, payload_length = ?
162
+ where id = ?`).run(next.prefix, next.offset, next.length, row.id);
163
+ result.retainedActivityPayloadsRewritten += 1;
164
+ }
165
+ }
166
+ sqlite.prepare("delete from bash_output_streams where id in (select output_id from maintenance_eligible_bash)").run();
167
+ sqlite.prepare("delete from activity_audit_events where activity_id in (select activity_id from maintenance_eligible_activities)").run();
168
+ sqlite.prepare("delete from activity_host_turns where turn_id in (select turn_id from maintenance_eligible_turns)").run();
169
+ });
170
+ try {
171
+ mutate.immediate();
172
+ }
173
+ catch (error) {
174
+ for (const prefix of newPrefixes)
175
+ removeSegmentedPrefix(stateDir, prefix);
176
+ throw error;
177
+ }
178
+ for (const prefix of oldActivityPrefixes) {
179
+ if (activityPrefixReferenced(sqlite, prefix))
180
+ continue;
181
+ result.removedSegmentFiles += removeSegmentedPrefix(stateDir, prefix);
182
+ }
183
+ for (const prefix of oldBashPrefixes) {
184
+ if (bashPrefixReferenced(sqlite, prefix))
185
+ continue;
186
+ result.removedSegmentFiles += removeSegmentedPrefix(stateDir, prefix);
187
+ }
188
+ return result;
189
+ }
190
+ finally {
191
+ sqlite.close();
192
+ }
193
+ }
194
+ function createEligibilityTables(sqlite, cutoff) {
195
+ sqlite.exec(`
196
+ drop table if exists temp.maintenance_eligible_activities;
197
+ drop table if exists temp.maintenance_eligible_turns;
198
+ drop table if exists temp.maintenance_eligible_bash;
199
+ create temp table maintenance_eligible_activities (activity_id text primary key);
200
+ create temp table maintenance_eligible_turns (turn_id text primary key);
201
+ create temp table maintenance_eligible_bash (output_id text primary key);
202
+ `);
203
+ const eligible = eligibleActivityCte(sqlite);
204
+ sqlite.prepare(`${eligible}
205
+ insert into maintenance_eligible_activities(activity_id)
206
+ select activity_id from eligible_activities`).run(cutoff);
207
+ sqlite.prepare(`${eligible}
208
+ insert into maintenance_eligible_turns(turn_id)
209
+ select turn_id from eligible_turns`).run(cutoff);
210
+ sqlite.prepare(`insert into maintenance_eligible_bash(output_id)
211
+ select id from bash_output_streams
212
+ where status <> 'running'
213
+ and activity_id in (select activity_id from maintenance_eligible_activities)`).run();
214
+ }
215
+ function historicalCounts(sqlite) {
216
+ const activity = sqlite.prepare(`select count(*) as events,
217
+ count(distinct activity_id) as activities,
218
+ coalesce(sum(payload_length), 0) as bytes
219
+ from activity_audit_events
220
+ where activity_id in (select activity_id from maintenance_eligible_activities)`).get();
221
+ const bash = sqlite.prepare(`select count(*) as streams, coalesce(sum(${bashBytesExpression(sqlite)}), 0) as bytes
222
+ from bash_output_streams
223
+ where id in (select output_id from maintenance_eligible_bash)`).get();
224
+ const hostTurns = Number(sqlite.prepare("select count(*) as count from maintenance_eligible_turns").get().count);
225
+ return {
226
+ activities: activity.activities,
227
+ activityEvents: activity.events,
228
+ activityPayloadBytes: activity.bytes,
229
+ bashStreams: bash.streams,
230
+ bashPayloadBytes: bash.bytes,
231
+ hostTurns,
232
+ removedSegmentFiles: 0,
233
+ retainedActivityPayloadsRewritten: 0,
234
+ };
235
+ }
236
+ function requireHistoricalSchema(sqlite) {
237
+ for (const table of ["activity_audit_events", "activity_host_turns", "bash_output_streams"]) {
238
+ if (!tableExists(sqlite, table)) {
239
+ throw new Error(`ForgeRelay state is missing ${table}; start the current ForgeRelay once before pruning history.`);
240
+ }
241
+ }
242
+ for (const column of ["payload_file", "payload_offset", "payload_length"]) {
243
+ if (!columnExists(sqlite, "activity_audit_events", column)) {
244
+ throw new Error(`ForgeRelay Activity state is missing ${column}; start the current ForgeRelay once before pruning history.`);
245
+ }
246
+ }
247
+ }
248
+ function readProtectedCounts(stateDir) {
249
+ const path = databasePath(stateDir);
250
+ if (!existsSync(path))
251
+ return emptyProtectedCounts();
252
+ const sqlite = new Database(path, { readonly: true, fileMustExist: true });
253
+ sqlite.pragma("query_only = ON");
254
+ try {
255
+ const runningBashStreams = tableExists(sqlite, "bash_output_streams")
256
+ ? Number(sqlite.prepare("select count(*) as count from bash_output_streams where status = 'running'").get().count)
257
+ : 0;
258
+ const activeSubagentRuns = tableExists(sqlite, "local_agent_sessions")
259
+ ? Number(sqlite.prepare(columnExists(sqlite, "local_agent_sessions", "active_run_id")
260
+ ? "select count(*) as count from local_agent_sessions where status = 'running' or active_run_id is not null"
261
+ : "select count(*) as count from local_agent_sessions where status = 'running'").get().count)
262
+ : 0;
263
+ return {
264
+ ...emptyProtectedCounts(),
265
+ runningBashStreams,
266
+ activeSubagentRuns,
267
+ };
268
+ }
269
+ finally {
270
+ sqlite.close();
271
+ }
272
+ }
273
+ function emptyProtectedCounts() {
274
+ return {
275
+ persistentWorkspaceIdentity: true,
276
+ workspaceTasks: true,
277
+ namedCheckpoints: true,
278
+ managedBranches: true,
279
+ runningBashStreams: 0,
280
+ activeSubagentRuns: 0,
281
+ };
282
+ }
283
+ function pruneAdministrativeState(stateDir) {
284
+ const protectedIds = protectedWorkspaceIds(stateDir);
285
+ let reviewRefs = 0;
286
+ for (const repository of workspaceRepositories(stateDir)) {
287
+ const listed = gitOutput(repository, ["for-each-ref", "--format=%(refname)", "refs/forgerelay/review"]);
288
+ if (listed === undefined)
289
+ continue;
290
+ for (const ref of listed.split(/\r?\n/).map((value) => value.trim()).filter(Boolean)) {
291
+ const match = REVIEW_REF.exec(ref);
292
+ if (!match || protectedIds.has(match[1]))
293
+ continue;
294
+ const removed = spawnSync("git", ["-C", repository, "update-ref", "-d", ref], {
295
+ encoding: "utf8",
296
+ windowsHide: true,
297
+ shell: false,
298
+ });
299
+ if (removed.error || removed.status !== 0) {
300
+ throw new Error(`Unable to remove orphan ForgeRelay review ref ${ref}: ${removed.stderr.trim() || removed.error?.message || "git update-ref failed"}`);
301
+ }
302
+ reviewRefs += 1;
303
+ }
304
+ }
305
+ let orphanWorkspaceStateDirectories = 0;
306
+ const workspacesDir = join(stateDir, "workspaces");
307
+ let entries;
308
+ try {
309
+ entries = readdirSync(workspacesDir);
310
+ }
311
+ catch (error) {
312
+ if (isErrno(error, "ENOENT"))
313
+ return { reviewRefs, orphanWorkspaceStateDirectories };
314
+ throw error;
315
+ }
316
+ const persistentIds = persistentWorkspaceIds(stateDir);
317
+ for (const name of entries) {
318
+ if (!WORKSPACE_ID.test(name) || persistentIds.has(name))
319
+ continue;
320
+ const path = join(workspacesDir, name);
321
+ if (!isDirectoryEmpty(path))
322
+ continue;
323
+ try {
324
+ rmdirSync(path);
325
+ orphanWorkspaceStateDirectories += 1;
326
+ }
327
+ catch (error) {
328
+ if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTEMPTY"))
329
+ throw error;
330
+ }
331
+ }
332
+ return { reviewRefs, orphanWorkspaceStateDirectories };
333
+ }
334
+ function protectedWorkspaceIds(stateDir) {
335
+ const ids = persistentWorkspaceIds(stateDir);
336
+ const workspacesDir = join(stateDir, "workspaces");
337
+ let entries;
338
+ try {
339
+ entries = readdirSync(workspacesDir);
340
+ }
341
+ catch (error) {
342
+ if (isErrno(error, "ENOENT"))
343
+ return ids;
344
+ throw error;
345
+ }
346
+ for (const name of entries) {
347
+ if (!WORKSPACE_ID.test(name))
348
+ continue;
349
+ const dir = join(workspacesDir, name);
350
+ if (!isDirectoryEmpty(dir))
351
+ ids.add(name);
352
+ }
353
+ return ids;
354
+ }
355
+ function persistentWorkspaceIds(stateDir) {
356
+ const ids = new Set();
357
+ const path = databasePath(stateDir);
358
+ if (!existsSync(path))
359
+ return ids;
360
+ const sqlite = new Database(path, { readonly: true, fileMustExist: true });
361
+ sqlite.pragma("query_only = ON");
362
+ try {
363
+ if (tableExists(sqlite, "workspace_sessions")) {
364
+ const rows = sqlite.prepare("select id from workspace_sessions").all();
365
+ for (const row of rows)
366
+ ids.add(row.id);
367
+ }
368
+ if (tableExists(sqlite, "workspace_session_aliases")) {
369
+ const rows = sqlite.prepare("select alias_id from workspace_session_aliases").all();
370
+ for (const row of rows)
371
+ ids.add(row.alias_id);
372
+ }
373
+ }
374
+ finally {
375
+ sqlite.close();
376
+ }
377
+ return ids;
378
+ }
379
+ function workspaceRepositories(stateDir) {
380
+ const repositories = new Set();
381
+ const path = databasePath(stateDir);
382
+ if (!existsSync(path))
383
+ return repositories;
384
+ const sqlite = new Database(path, { readonly: true, fileMustExist: true });
385
+ sqlite.pragma("query_only = ON");
386
+ try {
387
+ if (!tableExists(sqlite, "workspace_sessions"))
388
+ return repositories;
389
+ const rows = sqlite.prepare("select root, source_root from workspace_sessions").all();
390
+ for (const row of rows) {
391
+ const candidate = row.source_root ?? row.root;
392
+ if (!candidate || !existsSync(candidate))
393
+ continue;
394
+ const gitRoot = gitOutput(candidate, ["rev-parse", "--show-toplevel"]);
395
+ if (gitRoot)
396
+ repositories.add(gitRoot);
397
+ }
398
+ return repositories;
399
+ }
400
+ finally {
401
+ sqlite.close();
402
+ }
403
+ }
404
+ function activityPrefixReferenced(sqlite, prefix) {
405
+ return Boolean(sqlite.prepare("select 1 from activity_audit_events where payload_file = ? limit 1").get(prefix));
406
+ }
407
+ function bashPrefixReferenced(sqlite, prefix) {
408
+ return Boolean(sqlite.prepare(`select 1 from bash_output_streams
409
+ where log_file = ? or command_file = ? or error_file = ?
410
+ limit 1`).get(prefix, prefix, prefix));
411
+ }
412
+ function removeSegmentedPrefix(stateDir, prefix) {
413
+ const prefixPath = resolveStateRelativePath(stateDir, prefix);
414
+ const directory = dirname(prefixPath);
415
+ const stem = basename(prefixPath);
416
+ let entries;
417
+ try {
418
+ entries = readdirSync(directory);
419
+ }
420
+ catch (error) {
421
+ if (isErrno(error, "ENOENT"))
422
+ return 0;
423
+ throw error;
424
+ }
425
+ const pattern = new RegExp(`^${escapeRegExp(stem)}\\.\\d{6}\\.log$`);
426
+ let removed = 0;
427
+ for (const entry of entries) {
428
+ if (!pattern.test(entry))
429
+ continue;
430
+ rmSync(join(directory, entry), { force: true });
431
+ removed += 1;
432
+ }
433
+ return removed;
434
+ }
435
+ function isDirectoryEmpty(path) {
436
+ try {
437
+ return readdirSync(path).length === 0;
438
+ }
439
+ catch (error) {
440
+ if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR"))
441
+ return false;
442
+ throw error;
443
+ }
444
+ }
445
+ function gitOutput(root, args) {
446
+ const result = spawnSync("git", ["-C", root, ...args], {
447
+ encoding: "utf8",
448
+ windowsHide: true,
449
+ shell: false,
450
+ });
451
+ if (result.error || result.status !== 0)
452
+ return undefined;
453
+ return result.stdout.trim();
454
+ }
455
+ function emptyHistoricalPruneResult() {
456
+ return {
457
+ activities: 0,
458
+ activityEvents: 0,
459
+ activityPayloadBytes: 0,
460
+ bashStreams: 0,
461
+ bashPayloadBytes: 0,
462
+ hostTurns: 0,
463
+ removedSegmentFiles: 0,
464
+ retainedActivityPayloadsRewritten: 0,
465
+ };
466
+ }
467
+ function escapeRegExp(value) {
468
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
469
+ }
470
+ function formatBytes(bytes) {
471
+ if (bytes < 1024)
472
+ return `${bytes} B`;
473
+ if (bytes < 1024 * 1024)
474
+ return `${(bytes / 1024).toFixed(1)} KiB`;
475
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
476
+ }
477
+ function isErrno(error, code) {
478
+ return error instanceof Error && "code" in error && error.code === code;
479
+ }