@akira-tl/forgerelay 0.9.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +10 -4
  3. package/dist/cli/init.js +111 -24
  4. package/dist/cli/maintenance-prune.js +479 -0
  5. package/dist/cli/maintenance-retention.js +93 -0
  6. package/dist/cli/maintenance.js +598 -0
  7. package/dist/cli/setup-support.js +21 -0
  8. package/dist/cli/shell/setup.js +99 -0
  9. package/dist/cli.js +72 -16
  10. package/dist/mcp/hooks/hooks.js +6 -3
  11. package/dist/mcp/oauth/public-url.js +3 -0
  12. package/dist/mcp/oauth/router.js +14 -9
  13. package/dist/mcp/process/process-platform.js +24 -26
  14. package/dist/mcp/process/process-sessions.js +6 -3
  15. package/dist/mcp/server/transport/http-server.js +17 -9
  16. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +6 -0
  17. package/dist/mcp/server/workspace/runtime/workspace-tools.js +1 -1
  18. package/dist/mcp/server-instructions.js +10 -1
  19. package/dist/runtime/config/config.js +60 -6
  20. package/dist/runtime/instructions/shell-instructions.js +100 -0
  21. package/dist/runtime/security/runtime-privilege.js +120 -0
  22. package/dist/runtime/shell/command-shell-runtime.js +235 -0
  23. package/dist/runtime/state/runtime-lease.js +109 -0
  24. package/dist/ui/.vite/manifest.json +276 -276
  25. package/dist/ui/activity-panel-app.html +2 -2
  26. package/dist/ui/assets/{activity-panel-app-Sl1Jro8v.js → activity-panel-app-BkODhDLg.js} +1 -1
  27. package/dist/ui/assets/{activity-panel-app-7Xe0alhm.css → activity-panel-app-DkfJzTZ3.css} +1 -1
  28. package/dist/ui/assets/{angular-html-B_yn91wW.js → angular-html-DjuELMdF.js} +1 -1
  29. package/dist/ui/assets/{angular-ts-DSgtloKz.js → angular-ts-2EXVZIIX.js} +1 -1
  30. package/dist/ui/assets/{apl-NatWtF8X.js → apl-Bb4IeT5V.js} +1 -1
  31. package/dist/ui/assets/{astro-BYFEVBuN.js → astro-Bjavu3nT.js} +1 -1
  32. package/dist/ui/assets/{blade-DoO7yvMR.js → blade-DKW2uHZo.js} +1 -1
  33. package/dist/ui/assets/{c-Rl2hMCcg.js → c-oLeHmzbj.js} +1 -1
  34. package/dist/ui/assets/{cobol-ByC5mWiB.js → cobol-CcqFMr5T.js} +1 -1
  35. package/dist/ui/assets/{coffee-Dez77Qov.js → coffee-BA20zqRF.js} +1 -1
  36. package/dist/ui/assets/{cpp-DtaAQT9h.js → cpp-D750WmDa.js} +1 -1
  37. package/dist/ui/assets/{crystal-Bgmbf8ec.js → crystal-Cv5I6OMI.js} +1 -1
  38. package/dist/ui/assets/{css-C1tjBtVN.js → css-CjPPWPeH.js} +1 -1
  39. package/dist/ui/assets/{edge-DeH6u8Wg.js → edge-aYRuudEZ.js} +1 -1
  40. package/dist/ui/assets/{elixir-_H3KnmWT.js → elixir-ChLitmz7.js} +1 -1
  41. package/dist/ui/assets/{elm-CsCe3U28.js → elm-Fs7L7u36.js} +1 -1
  42. package/dist/ui/assets/{erb-CbM-L2g8.js → erb-B-qXHxN1.js} +1 -1
  43. package/dist/ui/assets/{git-rebase-D0s5pDh9.js → git-rebase-DOVN5OEg.js} +1 -1
  44. package/dist/ui/assets/{glimmer-js-BfOToq_A.js → glimmer-js-BFt_vCTW.js} +1 -1
  45. package/dist/ui/assets/{glimmer-ts-CCUOkL2K.js → glimmer-ts-ufCHlR6C.js} +1 -1
  46. package/dist/ui/assets/{glsl-DH4ec9dE.js → glsl-Ex4JXGuC.js} +1 -1
  47. package/dist/ui/assets/{graphql-DilAMBoI.js → graphql-pQPXtBk5.js} +1 -1
  48. package/dist/ui/assets/{hack-BDkNDZlF.js → hack-B5hDguhd.js} +1 -1
  49. package/dist/ui/assets/{haml-D_QBp6K7.js → haml-BrNeI29U.js} +1 -1
  50. package/dist/ui/assets/{handlebars-DePUrTpC.js → handlebars-DCqTsL9-.js} +1 -1
  51. package/dist/ui/assets/{heavy-payload-DOwZpO6n.js → heavy-payload-DsmmHgvt.js} +3 -3
  52. package/dist/ui/assets/{html-By4dNnM4.js → html-D8k0CC9V.js} +1 -1
  53. package/dist/ui/assets/{html-derivative-DDw9Ef4Q.js → html-derivative-CC8flWmC.js} +1 -1
  54. package/dist/ui/assets/{http-B0QxWc9M.js → http-BYnWqNep.js} +1 -1
  55. package/dist/ui/assets/{hurl-vMQ4lda1.js → hurl-BND-z85s.js} +1 -1
  56. package/dist/ui/assets/{java-DYRe2lWG.js → java-Dug0oB9T.js} +1 -1
  57. package/dist/ui/assets/{javascript-CxyGqwcL.js → javascript-oDJ7WITC.js} +1 -1
  58. package/dist/ui/assets/{jinja-JUwV-fZt.js → jinja-Cv0t_F1U.js} +1 -1
  59. package/dist/ui/assets/{jison-7ZqCEvS8.js → jison-Car3MF55.js} +1 -1
  60. package/dist/ui/assets/{json-dbltgiiA.js → json-CEViCsf4.js} +1 -1
  61. package/dist/ui/assets/{jsx-oDopT7YQ.js → jsx-BfZ0zgSH.js} +1 -1
  62. package/dist/ui/assets/{julia-CkKiNSav.js → julia-CmX0b3Tn.js} +1 -1
  63. package/dist/ui/assets/{just-DuqCAKJ3.js → just-BuugLCVk.js} +1 -1
  64. package/dist/ui/assets/{latex-sudcRDSn.js → latex-FpTXqO03.js} +1 -1
  65. package/dist/ui/assets/{liquid-Di-08Js7.js → liquid-B37OzUFu.js} +1 -1
  66. package/dist/ui/assets/{lua-YlqdMoV0.js → lua-BHjcb8oq.js} +1 -1
  67. package/dist/ui/assets/{marko-CJRNyJDb.js → marko-CApj_fsH.js} +1 -1
  68. package/dist/ui/assets/{mdc-t12CzZHs.js → mdc-Ed5vQWq8.js} +1 -1
  69. package/dist/ui/assets/{nginx-57-b4XwT.js → nginx-u_rVEBox.js} +1 -1
  70. package/dist/ui/assets/{nim-BzrswYQa.js → nim-Ba9EdchT.js} +1 -1
  71. package/dist/ui/assets/{perl-D9JR_iIh.js → perl-BpU_6Wvz.js} +1 -1
  72. package/dist/ui/assets/{php-Py2gFHil.js → php-bKabofky.js} +1 -1
  73. package/dist/ui/assets/{pug-PzLLy0h1.js → pug-C_aLNkqe.js} +1 -1
  74. package/dist/ui/assets/{qml-By8fwgV4.js → qml-D9VisnRy.js} +1 -1
  75. package/dist/ui/assets/{r-CcezTbLq.js → r-CYqB1sLx.js} +1 -1
  76. package/dist/ui/assets/{razor-dG-vdhL_.js → razor-DNJKI8Rs.js} +1 -1
  77. package/dist/ui/assets/{regexp-BlsQ-N_8.js → regexp-DHrTHXkz.js} +1 -1
  78. package/dist/ui/assets/{rst-C2awDCat.js → rst-CbNdSFHv.js} +1 -1
  79. package/dist/ui/assets/{ruby-zA2W_49d.js → ruby-FyKG-pkF.js} +1 -1
  80. package/dist/ui/assets/{sas-D49bhyaN.js → sas-BbSPDl3X.js} +1 -1
  81. package/dist/ui/assets/{scss-BQb-cp01.js → scss-2nX9g6gD.js} +1 -1
  82. package/dist/ui/assets/{shellscript-Dga4H_Ni.js → shellscript-CmON6x2x.js} +1 -1
  83. package/dist/ui/assets/{shellsession-n3-5CqcF.js → shellsession-DtqIGlAX.js} +1 -1
  84. package/dist/ui/assets/{soy-XYBOf_BA.js → soy-BzUJMmJT.js} +1 -1
  85. package/dist/ui/assets/{sql-ET4u5Cpl.js → sql-CgVcFZmb.js} +1 -1
  86. package/dist/ui/assets/{stata-CLHcJHO_.js → stata-CNitlMot.js} +1 -1
  87. package/dist/ui/assets/{surrealql-BdjQ60T4.js → surrealql-BA8-xsRZ.js} +1 -1
  88. package/dist/ui/assets/{svelte-B2RPtKJe.js → svelte-El02yAxv.js} +1 -1
  89. package/dist/ui/assets/{templ-CrByPW_W.js → templ-DeUQ84aR.js} +1 -1
  90. package/dist/ui/assets/{tex-DZBkg363.js → tex-Cu-FqpPS.js} +1 -1
  91. package/dist/ui/assets/{ts-tags-DJR3Gphb.js → ts-tags-X42N6TGA.js} +1 -1
  92. package/dist/ui/assets/{tsx-CajzROyy.js → tsx-C1aPp9rY.js} +1 -1
  93. package/dist/ui/assets/{twig-BZjk5Zda.js → twig-Cj1Q4SQc.js} +1 -1
  94. package/dist/ui/assets/{typescript-B-EWToLj.js → typescript-Du9lVt1G.js} +1 -1
  95. package/dist/ui/assets/{vue-D_rHCCr8.js → vue-_uMGbFv8.js} +1 -1
  96. package/dist/ui/assets/{vue-html-CC4WlvVt.js → vue-html-D61naG-Q.js} +1 -1
  97. package/dist/ui/assets/{vue-vine-BgNMVgpk.js → vue-vine-CkFae4yV.js} +1 -1
  98. package/dist/ui/assets/{xml-BI86-WbH.js → xml-Cbwsc6ws.js} +1 -1
  99. package/dist/ui/assets/{xsl-DXeLZV4J.js → xsl-NyBx72XB.js} +1 -1
  100. package/dist/ui/assets/{yaml-60_PFlWa.js → yaml-Cgb6Uq9u.js} +1 -1
  101. package/dist/workspaces/context.js +32 -5
  102. package/dist/workspaces/presentation/workspace-presentation.js +1 -0
  103. package/dist/workspaces/sessions.js +2 -0
  104. package/dist/workspaces.js +2 -1
  105. package/docs/configuration.md +101 -14
  106. package/package.json +2 -2
  107. package/scripts/release/release-gate.test.mjs +1 -0
  108. package/scripts/release-proof.mjs +85 -9
  109. package/scripts/release-proof.test.mjs +79 -1
@@ -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
+ }
@@ -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
+ }