@logicseek/hello-cc 0.1.9 → 1.0.1

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 (99) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +50 -3
  3. package/README.zh-CN.md +48 -3
  4. package/bin/hcc.mjs +3 -5656
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/guide.md +97 -3
  12. package/docs/guide.zh-CN.md +89 -2
  13. package/lib/cli/app.mjs +644 -0
  14. package/lib/cli/commands/coordination.mjs +249 -0
  15. package/lib/cli/commands/doctor.mjs +81 -0
  16. package/lib/cli/commands/gc.mjs +1002 -0
  17. package/lib/cli/commands/hook.mjs +205 -0
  18. package/lib/cli/commands/install.mjs +294 -0
  19. package/lib/cli/commands/lifecycle.mjs +101 -0
  20. package/lib/cli/commands/lock.mjs +197 -0
  21. package/lib/cli/commands/misc.mjs +76 -0
  22. package/lib/cli/commands/msg.mjs +152 -0
  23. package/lib/cli/commands/peer.mjs +249 -0
  24. package/lib/cli/commands/query.mjs +96 -0
  25. package/lib/cli/commands/run.mjs +463 -0
  26. package/lib/cli/commands/task.mjs +405 -0
  27. package/lib/cli/commands/team.mjs +131 -0
  28. package/lib/cli/commands/tmux.mjs +838 -0
  29. package/lib/cli/commands/up.mjs +135 -0
  30. package/lib/cli-args.mjs +16 -2
  31. package/lib/coordination-state.mjs +112 -51
  32. package/lib/core/coordination/automation.mjs +5 -8
  33. package/lib/core/coordination/clock-safety.mjs +323 -0
  34. package/lib/core/coordination/event-retention.mjs +17 -0
  35. package/lib/core/coordination/gc-plan.mjs +614 -0
  36. package/lib/core/coordination/lease-renewal.mjs +102 -0
  37. package/lib/core/coordination/lock-evidence.mjs +77 -0
  38. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  39. package/lib/core/coordination/tasks.mjs +156 -36
  40. package/lib/core/peers/evidence-runtime.mjs +312 -0
  41. package/lib/core/peers/evidence.mjs +103 -0
  42. package/lib/core/peers/hook-owner.mjs +32 -0
  43. package/lib/core/peers/liveness.mjs +44 -4
  44. package/lib/core/peers/peer-helpers.mjs +121 -0
  45. package/lib/core/peers/session.mjs +8 -2
  46. package/lib/core/peers/tmux-safety.mjs +290 -0
  47. package/lib/core/sessions/launch.mjs +13 -1
  48. package/lib/db/connection.mjs +104 -0
  49. package/lib/db/events.mjs +34 -0
  50. package/lib/db/migration-backup.mjs +225 -0
  51. package/lib/db/schema.mjs +102 -5
  52. package/lib/db/stores/peers.mjs +34 -0
  53. package/lib/integrations/peers/identity.mjs +62 -16
  54. package/lib/integrations/shims/script.mjs +111 -9
  55. package/lib/process/identity.mjs +222 -0
  56. package/lib/process/pty-lifecycle.mjs +149 -0
  57. package/lib/runtime/buffer-directory-lease.mjs +48 -0
  58. package/lib/runtime/buffer-evidence.mjs +336 -0
  59. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  60. package/lib/runtime/buffer-gc.mjs +270 -0
  61. package/lib/runtime/client.mjs +63 -7
  62. package/lib/runtime/fatal-shutdown.mjs +54 -0
  63. package/lib/runtime/project-path.mjs +139 -0
  64. package/lib/runtime/projects.mjs +320 -40
  65. package/lib/runtime/state.mjs +227 -27
  66. package/lib/shared/cleanup-error.mjs +54 -0
  67. package/lib/shared/clock-grace.mjs +106 -0
  68. package/lib/shared/errors.mjs +9 -0
  69. package/lib/shared/file-lock.mjs +377 -0
  70. package/lib/shared/json-file.mjs +6 -2
  71. package/lib/shared/redact.mjs +60 -0
  72. package/lib/shared/socket-lock-worker.mjs +250 -0
  73. package/lib/terminal/tmux.mjs +131 -27
  74. package/lib/tmux.mjs +4 -0
  75. package/lib/ui/help.mjs +53 -15
  76. package/lib/web/api-version.mjs +42 -0
  77. package/lib/web/auto-attach.mjs +185 -0
  78. package/lib/web/buffer-gc-runtime.mjs +175 -0
  79. package/lib/web/cookie-auth.mjs +119 -0
  80. package/lib/web/external-sessions.mjs +254 -0
  81. package/lib/web/http-routes.mjs +542 -0
  82. package/lib/web/http.mjs +135 -6
  83. package/lib/web/liveness-reaper.mjs +145 -0
  84. package/lib/web/peer-actions.mjs +132 -23
  85. package/lib/web/project-contexts.mjs +183 -0
  86. package/lib/web/pty-sessions.mjs +203 -0
  87. package/lib/web/runtime-main.mjs +717 -0
  88. package/lib/web/runtime.mjs +91 -36
  89. package/lib/web/session-serialize.mjs +0 -0
  90. package/lib/web/startup.mjs +335 -0
  91. package/lib/web/tls.mjs +365 -0
  92. package/lib/web/tmux-sessions.mjs +1361 -0
  93. package/lib/web/tmux-stream.mjs +166 -0
  94. package/lib/web/ui-template.mjs +152 -20
  95. package/package.json +6 -3
  96. package/scripts/check-free-identifiers.mjs +290 -0
  97. package/scripts/regression.mjs +5404 -273
  98. package/docs/architecture-split-review.md +0 -217
  99. package/docs/layout-split-stack-audit.md +0 -298
@@ -0,0 +1,1002 @@
1
+ // Garbage collection extracted from bin/hcc.mjs.
2
+ // Clock-safe GC: lock/history/buffer planning with fencing evidence, runtime
3
+ // arbitration for buffer files, and the manual hcc gc command.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { CliError } from '../../shared/errors.mjs';
8
+ import { tx } from '../../db/schema.mjs';
9
+ import { parseOpts, validateOpts, wantsHelp } from '../../cli-args.mjs';
10
+ import { printResult } from '../../format.mjs';
11
+ import { readClockGraceUntil, clockGraceSuppressed } from '../../shared/clock-grace.mjs';
12
+ import {
13
+ clockSafetyUnavailable,
14
+ observeClockSafetyInTransaction,
15
+ previewClockSafety
16
+ } from '../../core/coordination/clock-safety.mjs';
17
+ import {
18
+ captureGcLockSubjects,
19
+ captureHistoryGcPlan,
20
+ createHistoryGcSnapshot,
21
+ finalizeGcLockSubjects,
22
+ finalizeHistoryGcBatches,
23
+ runWithHistoryGcSnapshotCleanup,
24
+ runWithHistoryGcSnapshotCleanupAsync
25
+ } from '../../core/coordination/gc-plan.mjs';
26
+ import { runOptimisticEvidenceMutation } from '../../core/coordination/optimistic-evidence.mjs';
27
+ import { peerEvidenceAllowsReap } from '../../core/peers/evidence.mjs';
28
+ import { runtimePath, globalRuntimePath } from '../../runtime/paths.mjs';
29
+ import { readRuntime, reclaimRuntimePointerFiles } from '../../runtime/state.mjs';
30
+ import { runtimeRequest, runtimeBufferGcUnavailable } from '../../runtime/client.mjs';
31
+ import { collectBufferEvidence } from '../../runtime/buffer-evidence.mjs';
32
+ import { withBufferDirectoryLease } from '../../runtime/buffer-directory-lease.mjs';
33
+ import {
34
+ applyBufferPlan,
35
+ bufferPlanGcCutoffs,
36
+ deferBufferPlan,
37
+ planBufferFiles,
38
+ pruneBufferFiles
39
+ } from '../../runtime/buffer-gc.mjs';
40
+ import { applyClockSafeBufferPlan } from '../../runtime/buffer-gc-protocol.mjs';
41
+
42
+ export const RUNTIME_BUFFER_GC_TIMEOUT_MS = 30_000;
43
+
44
+ export function createGcCommands(deps) {
45
+ const {
46
+ connect, now, helpGc,
47
+ UNKNOWN_EVIDENCE_GRACE_SEC, BUFS_DIR_NAME,
48
+ peerMutationSubject, mutatePeerWithEvidence,
49
+ observeClockSafetyInTransactionOrThrow, observePeerEvidence
50
+ } = deps;
51
+
52
+ // ─── hcc gc ───────────────────────────────────────────────────────────────────
53
+
54
+ // Prune stale state. scope='full' always plans technical and history state, but
55
+ // manual history is applied only with `hcc gc --history --yes`.
56
+ // scope='auto' cleans only high-volume/ephemeral items (events, expired locks,
57
+ // stale bufs) so automatic background gc bounds DB growth without deleting
58
+ // user-meaningful history.
59
+ // `db` is used open; the caller owns its lifecycle.
60
+ function captureGcClockSubject(db, {
61
+ bufferGcCutoffs,
62
+ olderThanDays,
63
+ observedAt,
64
+ historyCategories,
65
+ includeStalePeers,
66
+ historySnapshot
67
+ }) {
68
+ const retentionSec = olderThanDays * 86400;
69
+ const cutoff = observedAt - retentionSec;
70
+ const lockSubjects = captureGcLockSubjects(db, observedAt);
71
+ const historyPlan = captureHistoryGcPlan(db, cutoff, {
72
+ categories: historyCategories,
73
+ snapshot: historySnapshot
74
+ });
75
+
76
+ const stalePeers = includeStalePeers
77
+ ? db.prepare(`
78
+ SELECT p.id
79
+ FROM peers p
80
+ WHERE p.last_seen_at <= ?
81
+ ORDER BY p.id ASC
82
+ `).all(cutoff).map(({ id }) => peerMutationSubject(db, id))
83
+ : [];
84
+
85
+ const ownerIds = new Set(lockSubjects.map(({ lock }) => lock.owner));
86
+ for (const subject of stalePeers) {
87
+ if (subject.peer?.id) ownerIds.add(subject.peer.id);
88
+ }
89
+ const owners = [...ownerIds].sort().map((owner) => ({
90
+ owner,
91
+ ...peerMutationSubject(db, owner)
92
+ }));
93
+
94
+ return {
95
+ technicalPlan: {
96
+ lockSubjects,
97
+ stalePeers
98
+ },
99
+ owners,
100
+ historyPlan,
101
+ gcCutoffs: [...historySnapshot.gcCutoffs, ...bufferGcCutoffs],
102
+ graceUntil: readClockGraceUntil(db),
103
+ olderThanDays,
104
+ observedAt
105
+ };
106
+ }
107
+
108
+ function observeGcClockSafety(ctx, db, {
109
+ bufferGcCutoffs,
110
+ olderThanDays,
111
+ observedAt,
112
+ historyCategories,
113
+ includeStalePeers,
114
+ historySnapshot,
115
+ bufferPathGuard = null
116
+ }) {
117
+ try {
118
+ return runOptimisticEvidenceMutation(db, {
119
+ attempts: 5,
120
+ capture: (subjectDb) => captureGcClockSubject(subjectDb, {
121
+ bufferGcCutoffs,
122
+ olderThanDays,
123
+ observedAt,
124
+ historyCategories,
125
+ includeStalePeers,
126
+ historySnapshot
127
+ }),
128
+ observe: (subject) => observeGcEvidence(ctx, subject),
129
+ same: (left, right) => JSON.stringify(left) === JSON.stringify(right),
130
+ beforeMutate: (subject, evidenceByOwner) => {
131
+ if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
132
+ return observeClockSafetyInTransactionOrThrow(
133
+ db,
134
+ gcClockSafetyOptions(subject, evidenceByOwner, olderThanDays)
135
+ );
136
+ },
137
+ changedMessage: 'GC subjects changed while clock evidence was being observed; retry',
138
+ mutate: (subject, evidenceByOwner, observation) => ({
139
+ ...observation,
140
+ subject,
141
+ evidenceByOwner,
142
+ expiredLockCount: subject.technicalPlan.lockSubjects.length
143
+ })
144
+ });
145
+ } catch (err) {
146
+ if (err instanceof CliError) throw err;
147
+ throw clockSafetyUnavailable(err);
148
+ }
149
+ }
150
+
151
+ function observeGcEvidence(ctx, subject) {
152
+ return new Map(subject.owners.map(({ owner, peer, binding }) => [
153
+ owner,
154
+ peer
155
+ ? observePeerEvidence(ctx, peer, binding)
156
+ : { state: 'unknown', reason: 'peer_missing' }
157
+ ]));
158
+ }
159
+
160
+ function gcClockSafetyOptions(subject, evidenceByOwner, olderThanDays) {
161
+ return {
162
+ operation: 'gc',
163
+ candidates: [
164
+ ...subject.technicalPlan.lockSubjects.map(({ lock }) => ({
165
+ boundary: Number(lock.expires_at),
166
+ evidence: evidenceByOwner.get(lock.owner)?.state || 'unknown',
167
+ owner: lock.owner,
168
+ resource: lock.resource
169
+ })),
170
+ ...subject.technicalPlan.stalePeers.map(({ peer }) => ({
171
+ boundary: Number(peer.last_seen_at) + olderThanDays * 86400,
172
+ evidence: evidenceByOwner.get(peer.id)?.state || 'unknown',
173
+ owner: peer.id
174
+ }))
175
+ ],
176
+ gcCutoffs: subject.gcCutoffs,
177
+ nowSec: subject.observedAt
178
+ };
179
+ }
180
+
181
+ function previewGcClockSafety(ctx, db, options) {
182
+ try {
183
+ const subject = captureGcClockSubject(db, options);
184
+ const evidenceByOwner = observeGcEvidence(ctx, subject);
185
+ const observation = previewClockSafety(
186
+ db,
187
+ gcClockSafetyOptions(subject, evidenceByOwner, options.olderThanDays)
188
+ );
189
+ return {
190
+ ...observation,
191
+ subject,
192
+ evidenceByOwner,
193
+ expiredLockCount: subject.technicalPlan.lockSubjects.length
194
+ };
195
+ } catch (error) {
196
+ if (error instanceof CliError) throw error;
197
+ throw clockSafetyUnavailable(error);
198
+ }
199
+ }
200
+
201
+ function bufferDirectoryPathForbidden(message, directory) {
202
+ return new CliError('PROJECT_PATH_FORBIDDEN', `${message}: ${directory}`);
203
+ }
204
+
205
+ function assertBufferDirectoryPathUnchanged(captured) {
206
+ let resolvedRoot;
207
+ try {
208
+ resolvedRoot = fs.realpathSync.native(captured.requestedRoot);
209
+ } catch (error) {
210
+ if (error?.code !== 'ENOENT') throw error;
211
+ throw bufferDirectoryPathForbidden(
212
+ 'Project root changed during buffer GC',
213
+ captured.requestedRoot
214
+ );
215
+ }
216
+ if (resolvedRoot !== captured.canonicalRoot) {
217
+ throw bufferDirectoryPathForbidden(
218
+ 'Project root changed during buffer GC',
219
+ captured.requestedRoot
220
+ );
221
+ }
222
+ for (const component of captured.components) {
223
+ let current;
224
+ try {
225
+ current = fs.lstatSync(component.path);
226
+ } catch (error) {
227
+ if (error?.code !== 'ENOENT') throw error;
228
+ throw bufferDirectoryPathForbidden(
229
+ 'Buffer directory path changed during lease acquisition',
230
+ captured.directory
231
+ );
232
+ }
233
+ if (current.isSymbolicLink() || !current.isDirectory() ||
234
+ current.dev !== component.stat.dev || current.ino !== component.stat.ino) {
235
+ throw bufferDirectoryPathForbidden(
236
+ 'Buffer directory path changed during lease acquisition',
237
+ captured.directory
238
+ );
239
+ }
240
+ }
241
+ if (!captured.exists) {
242
+ try {
243
+ fs.lstatSync(captured.directory);
244
+ } catch (error) {
245
+ if (error?.code === 'ENOENT') return;
246
+ throw error;
247
+ }
248
+ throw bufferDirectoryPathForbidden(
249
+ 'Buffer directory path changed during lease acquisition',
250
+ captured.directory
251
+ );
252
+ }
253
+ }
254
+
255
+ function captureRequiredBufferDirectoryComponent(component, description) {
256
+ const stat = fs.lstatSync(component);
257
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
258
+ throw bufferDirectoryPathForbidden(`${description} must be a real directory`, component);
259
+ }
260
+ return { path: component, stat };
261
+ }
262
+
263
+ function captureOptionalBufferDirectory(root, directoryName) {
264
+ const requestedRoot = path.resolve(root);
265
+ const canonicalRoot = fs.realpathSync.native(requestedRoot);
266
+ const stateDirectory = path.join(canonicalRoot, '.hello-cc');
267
+ const directory = path.join(stateDirectory, directoryName);
268
+ const components = [
269
+ captureRequiredBufferDirectoryComponent(canonicalRoot, 'Project root'),
270
+ captureRequiredBufferDirectoryComponent(stateDirectory, 'Project state directory')
271
+ ];
272
+
273
+ let stat;
274
+ try {
275
+ stat = fs.lstatSync(directory);
276
+ } catch (error) {
277
+ if (error?.code === 'ENOENT') {
278
+ const captured = {
279
+ requestedRoot,
280
+ canonicalRoot,
281
+ directory,
282
+ components,
283
+ exists: false
284
+ };
285
+ assertBufferDirectoryPathUnchanged(captured);
286
+ return captured;
287
+ }
288
+ throw error;
289
+ }
290
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
291
+ throw bufferDirectoryPathForbidden('Buffer directory must be a real directory', directory);
292
+ }
293
+ components.push({ path: directory, stat });
294
+ return { requestedRoot, canonicalRoot, directory, components, exists: true };
295
+ }
296
+
297
+ function collectGuardedBufferEvidence(collector, captured) {
298
+ const evidence = collector(captured.directory);
299
+ assertBufferDirectoryPathUnchanged(captured);
300
+ return evidence;
301
+ }
302
+
303
+ function withOptionalBufferDirectoryLease(captured, callback) {
304
+ assertBufferDirectoryPathUnchanged(captured);
305
+ if (!captured.exists) return null;
306
+ return withBufferDirectoryLease(captured.directory, (leasedDirectory) => {
307
+ const assertDirectoryUnchanged = () => {
308
+ assertBufferDirectoryPathUnchanged(captured);
309
+ if (fs.realpathSync.native(captured.directory) !== leasedDirectory) {
310
+ throw bufferDirectoryPathForbidden(
311
+ 'Buffer directory path changed during lease acquisition',
312
+ captured.directory
313
+ );
314
+ }
315
+ };
316
+ assertDirectoryUnchanged();
317
+ const result = callback(leasedDirectory, assertDirectoryUnchanged);
318
+ assertDirectoryUnchanged();
319
+ return result;
320
+ }, { createParent: false });
321
+ }
322
+
323
+ function runGc(ctx, db, {
324
+ olderThanDays = 7,
325
+ dryRun = false,
326
+ scope = 'full',
327
+ protectedBufFiles = null,
328
+ collectBufferEvidenceNow = null,
329
+ bufferCutoffMs = null,
330
+ skipBufferFiles = false,
331
+ history = false,
332
+ observedAt = null
333
+ } = {}) {
334
+ const bufferPathGuard = skipBufferFiles
335
+ ? null
336
+ : captureOptionalBufferDirectory(ctx.root, BUFS_DIR_NAME);
337
+ const auto = scope === 'auto';
338
+ const historyCategories = auto ? ['events'] : ['events', 'tasks', 'messages', 'handoffs'];
339
+ const applyHistory = auto || history;
340
+ const gcNow = Number.isSafeInteger(observedAt) && observedAt >= 0 ? observedAt : now();
341
+ const retentionSec = olderThanDays * 86400;
342
+ const fixedBufferCutoffMs = Number.isFinite(bufferCutoffMs)
343
+ ? bufferCutoffMs
344
+ : gcNow * 1000 - (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400000;
345
+ const bufferRetentionSec = (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400;
346
+ const preparedBufferEvidence = !skipBufferFiles && collectBufferEvidenceNow
347
+ ? collectGuardedBufferEvidence(collectBufferEvidenceNow, bufferPathGuard)
348
+ : null;
349
+ const bufferGcCutoffs = skipBufferFiles
350
+ ? []
351
+ : bufferDirectoryPaths(bufferPathGuard.directory).flatMap((file) => {
352
+ try {
353
+ const stat = fs.lstatSync(file);
354
+ return [Math.max(0, Math.floor(stat.mtimeMs / 1000) + bufferRetentionSec)];
355
+ } catch {
356
+ return [];
357
+ }
358
+ });
359
+ if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
360
+ bufferGcCutoffs.push(...(preparedBufferEvidence?.gcCutoffs || []));
361
+ const historySnapshot = createHistoryGcSnapshot(db, gcNow - retentionSec, {
362
+ categories: historyCategories,
363
+ retentionSec
364
+ });
365
+ return runWithHistoryGcSnapshotCleanup(
366
+ db,
367
+ historySnapshot,
368
+ () => runGcWithHistorySnapshot(ctx, db, {
369
+ olderThanDays,
370
+ dryRun,
371
+ auto,
372
+ applyHistory,
373
+ protectedBufFiles,
374
+ collectBufferEvidenceNow,
375
+ preparedBufferEvidence,
376
+ bufferCutoffMs: fixedBufferCutoffMs,
377
+ bufferRetentionSec,
378
+ skipBufferFiles,
379
+ bufferGcCutoffs,
380
+ historyCategories,
381
+ gcNow,
382
+ historySnapshot,
383
+ bufferPathGuard
384
+ }),
385
+ 'History GC run'
386
+ );
387
+ }
388
+
389
+ function runGcWithHistorySnapshot(ctx, db, {
390
+ olderThanDays,
391
+ dryRun,
392
+ auto,
393
+ applyHistory,
394
+ protectedBufFiles,
395
+ collectBufferEvidenceNow,
396
+ preparedBufferEvidence,
397
+ bufferCutoffMs,
398
+ bufferRetentionSec,
399
+ skipBufferFiles,
400
+ bufferGcCutoffs,
401
+ historyCategories,
402
+ gcNow,
403
+ historySnapshot,
404
+ bufferPathGuard = null,
405
+ clockObservation: preparedClockObservation = null,
406
+ forceDefer = false
407
+ }) {
408
+ const clockObservation = preparedClockObservation || observeGcClockSafety(ctx, db, {
409
+ bufferGcCutoffs,
410
+ olderThanDays,
411
+ observedAt: gcNow,
412
+ historyCategories,
413
+ includeStalePeers: !auto,
414
+ historySnapshot,
415
+ bufferPathGuard
416
+ });
417
+ const graceActive = forceDefer || clockGraceSuppressed(gcNow, clockObservation.graceUntil);
418
+ let databaseGraceActive = graceActive;
419
+ const beforeDatabaseMutate = () => {
420
+ if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
421
+ if (databaseGraceActive) return false;
422
+ try {
423
+ const observedAt = now();
424
+ const allowed = !clockGraceSuppressed(observedAt, readClockGraceUntil(db));
425
+ if (!allowed) {
426
+ databaseGraceActive = true;
427
+ return false;
428
+ }
429
+ if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
430
+ return true;
431
+ } catch (error) {
432
+ if (error instanceof CliError) throw error;
433
+ throw clockSafetyUnavailable(error);
434
+ }
435
+ };
436
+ const protectedBuffers = protectedBufFiles instanceof Set
437
+ ? protectedBufFiles
438
+ : new Set(protectedBufFiles || []);
439
+ const results = {
440
+ buf_files: 0,
441
+ protected_buf_files: 0,
442
+ deferred_buf_files: 0,
443
+ stale_peers: 0,
444
+ protected_stale_peers: 0,
445
+ deferred_stale_peers: 0,
446
+ old_events: 0,
447
+ old_tasks: 0,
448
+ old_messages: 0,
449
+ old_handoffs: 0,
450
+ protected_old_events: 0,
451
+ protected_old_tasks: 0,
452
+ protected_old_messages: 0,
453
+ protected_old_handoffs: Number(historySnapshot.protectedCounts?.handoffs || 0),
454
+ deferred_old_events: 0,
455
+ deferred_old_tasks: 0,
456
+ deferred_old_messages: 0,
457
+ deferred_old_handoffs: 0,
458
+ expired_locks: 0,
459
+ protected_expired_locks: 0,
460
+ deferred_expired_locks: graceActive ? clockObservation.expiredLockCount : 0,
461
+ deferred_unknown_peers: 0,
462
+ deferred_history: 0,
463
+ deferred_age_based: graceActive
464
+ };
465
+
466
+ // Buffer files (high-volume ephemeral). Auto keeps a shorter 7-day window.
467
+ const bufCutoffMs = Number.isFinite(bufferCutoffMs)
468
+ ? bufferCutoffMs
469
+ : gcNow * 1000 - (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400000;
470
+ if (!graceActive && !skipBufferFiles) {
471
+ const bufferResult = withOptionalBufferDirectoryLease(bufferPathGuard, (
472
+ leasedBufsDir,
473
+ assertDirectoryUnchanged
474
+ ) => {
475
+ const currentEvidence = collectBufferEvidenceNow
476
+ ? collectGuardedBufferEvidence(collectBufferEvidenceNow, bufferPathGuard)
477
+ : preparedBufferEvidence || { protectedPaths: protectedBuffers, unknownPaths: new Set(), gcCutoffs: [] };
478
+ const currentPlan = planBufferFiles({
479
+ directories: [leasedBufsDir],
480
+ cutoffMs: bufCutoffMs,
481
+ protectedPaths: currentEvidence.protectedPaths,
482
+ unknownPaths: currentEvidence.unknownPaths,
483
+ evidenceGcCutoffs: [
484
+ ...(preparedBufferEvidence?.gcCutoffs || []),
485
+ ...(currentEvidence.gcCutoffs || [])
486
+ ]
487
+ });
488
+ assertDirectoryUnchanged();
489
+ const currentObservedAt = now();
490
+ const currentObservation = tx(db, () => {
491
+ assertDirectoryUnchanged();
492
+ return observeClockSafetyInTransaction(db, {
493
+ operation: 'gc',
494
+ gcCutoffs: bufferPlanGcCutoffs(currentPlan, bufferRetentionSec),
495
+ nowSec: currentObservedAt
496
+ });
497
+ });
498
+ if (clockGraceSuppressed(currentObservedAt, currentObservation.graceUntil)) {
499
+ databaseGraceActive = true;
500
+ return deferBufferPlan(currentPlan);
501
+ }
502
+ if (dryRun) {
503
+ return {
504
+ deleted: currentPlan.deleteEntries.length,
505
+ protected: currentPlan.protectedEntries.length,
506
+ deferred: currentPlan.unknownEntries.length
507
+ };
508
+ }
509
+ return applyBufferPlan(currentPlan);
510
+ });
511
+ if (bufferResult !== null) {
512
+ results.buf_files = bufferResult.deleted;
513
+ results.protected_buf_files = bufferResult.protected;
514
+ results.deferred_buf_files = bufferResult.deferred;
515
+ }
516
+ }
517
+
518
+ // A clock jump can make every lock look expired at once. Preserve those rows
519
+ // for the grace window so acquisition and heartbeat logic can recover them.
520
+ const expiredLockSubjects = clockObservation.subject.technicalPlan.lockSubjects;
521
+ if (graceActive) {
522
+ results.deferred_expired_locks = Math.max(results.deferred_expired_locks, expiredLockSubjects.length);
523
+ } else {
524
+ const lockResult = finalizeGcLockSubjects(
525
+ db,
526
+ expiredLockSubjects,
527
+ clockObservation.evidenceByOwner,
528
+ {
529
+ dryRun,
530
+ beforeMutate: dryRun ? undefined : beforeDatabaseMutate
531
+ }
532
+ );
533
+ results.expired_locks = lockResult.deleted;
534
+ results.protected_expired_locks = lockResult.live;
535
+ results.deferred_expired_locks += lockResult.deferred;
536
+ }
537
+
538
+ if (!auto && databaseGraceActive) {
539
+ results.deferred_stale_peers = clockObservation.subject.technicalPlan.stalePeers.length;
540
+ } else if (!auto) {
541
+ // Stale peers (no heartbeat in N days)
542
+ const stalePeers = clockObservation.subject.technicalPlan.stalePeers;
543
+ for (const subject of stalePeers) {
544
+ const p = subject.peer;
545
+ const evidence = clockObservation.evidenceByOwner.get(p.id) || { state: 'unknown' };
546
+ if (evidence.state === 'live') {
547
+ results.protected_stale_peers++;
548
+ continue;
549
+ }
550
+ if (evidence.state === 'unknown' && !peerEvidenceAllowsReap(evidence, {
551
+ nowSec: gcNow,
552
+ lastSeenAt: Number(p.last_seen_at || 0),
553
+ staleAfterSec: UNKNOWN_EVIDENCE_GRACE_SEC,
554
+ graceUntil: clockObservation.graceUntil
555
+ })) {
556
+ results.deferred_unknown_peers++;
557
+ results.deferred_stale_peers++;
558
+ continue;
559
+ }
560
+ if (dryRun) {
561
+ results.stale_peers++;
562
+ continue;
563
+ }
564
+ if (databaseGraceActive) {
565
+ results.deferred_stale_peers++;
566
+ continue;
567
+ }
568
+ const removed = mutatePeerWithEvidence(db, ctx, p.id, () => {
569
+ db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(p.id);
570
+ db.prepare('DELETE FROM peers WHERE id = ?').run(p.id);
571
+ }, {
572
+ acceptEvidence: (currentEvidence, currentSubject) => peerEvidenceAllowsReap(currentEvidence, {
573
+ nowSec: now(),
574
+ lastSeenAt: Number(currentSubject.peer.last_seen_at || 0),
575
+ staleAfterSec: UNKNOWN_EVIDENCE_GRACE_SEC,
576
+ graceUntil: readClockGraceUntil(db)
577
+ }),
578
+ beforeMutate: beforeDatabaseMutate
579
+ });
580
+ if (removed.changed) results.stale_peers++;
581
+ else if (removed.evidence?.state === 'live') results.protected_stale_peers++;
582
+ else {
583
+ if (removed.evidence?.state === 'unknown') results.deferred_unknown_peers++;
584
+ results.deferred_stale_peers++;
585
+ }
586
+ }
587
+
588
+ }
589
+
590
+ const eligibleHistory = historySnapshot.eligibleCounts;
591
+ if (databaseGraceActive) {
592
+ results.deferred_old_events = Number(eligibleHistory.events || 0);
593
+ results.deferred_old_tasks = Number(eligibleHistory.tasks || 0);
594
+ results.deferred_old_messages = Number(eligibleHistory.messages || 0);
595
+ results.deferred_old_handoffs = Number(eligibleHistory.handoffs || 0);
596
+ results.deferred_history = results.deferred_old_events + results.deferred_old_tasks +
597
+ results.deferred_old_messages + results.deferred_old_handoffs;
598
+ } else if (!applyHistory) {
599
+ results.protected_old_events = Number(eligibleHistory.events || 0);
600
+ results.protected_old_tasks = Number(eligibleHistory.tasks || 0);
601
+ results.protected_old_messages = Number(eligibleHistory.messages || 0);
602
+ results.protected_old_handoffs += Number(eligibleHistory.handoffs || 0);
603
+ } else {
604
+ const historyResult = finalizeHistoryGcBatches(db, clockObservation.subject.historyPlan, {
605
+ dryRun,
606
+ dropSnapshot: false,
607
+ beforeMutate: dryRun ? undefined : beforeDatabaseMutate
608
+ });
609
+ results.old_events = historyResult.old_events;
610
+ results.old_tasks = historyResult.old_tasks;
611
+ results.old_messages = historyResult.old_messages;
612
+ results.old_handoffs = historyResult.old_handoffs;
613
+ results.deferred_old_events = Math.max(0, Number(eligibleHistory.events || 0) - results.old_events);
614
+ results.deferred_old_tasks = Math.max(0, Number(eligibleHistory.tasks || 0) - results.old_tasks);
615
+ results.deferred_old_messages = Math.max(0, Number(eligibleHistory.messages || 0) - results.old_messages);
616
+ results.deferred_old_handoffs = Math.max(0, Number(eligibleHistory.handoffs || 0) - results.old_handoffs);
617
+ results.deferred_history = results.deferred_old_events + results.deferred_old_tasks +
618
+ results.deferred_old_messages + results.deferred_old_handoffs;
619
+ }
620
+
621
+ results.deferred_age_based = results.deferred_age_based || databaseGraceActive;
622
+
623
+ return results;
624
+ }
625
+
626
+ function bufferDirectory(ctx) {
627
+ return path.resolve(path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME));
628
+ }
629
+
630
+ function bufferDirectoryPaths(directory) {
631
+ try {
632
+ return fs.readdirSync(directory).map((name) => path.resolve(directory, name));
633
+ } catch {
634
+ return [];
635
+ }
636
+ }
637
+
638
+ function deferEligibleBufferFiles(directory, cutoffMs) {
639
+ return pruneBufferFiles({
640
+ directories: [directory],
641
+ cutoffMs,
642
+ unknownPaths: bufferDirectoryPaths(directory),
643
+ dryRun: true
644
+ });
645
+ }
646
+
647
+ function localBufferEvidence(ctx, db, directory, unknownTracker = null, candidatePaths = null) {
648
+ return collectBufferEvidence({
649
+ directories: [directory],
650
+ projectDbs: [{ ctx, db }],
651
+ observePeer: observePeerEvidence,
652
+ unknownTracker,
653
+ candidatePaths
654
+ });
655
+ }
656
+
657
+ function runtimePointerPresent(ctx) {
658
+ return Boolean(process.env.HCC_RUNTIME_URL ||
659
+ fs.existsSync(runtimePath(ctx)) ||
660
+ fs.existsSync(globalRuntimePath()));
661
+ }
662
+
663
+ function reclaimUnavailableRuntimePointers(ctx, { dryRun = false } = {}) {
664
+ if (process.env.HCC_RUNTIME_URL) return { allowed: false, gcCutoffs: [] };
665
+ const gcCutoffs = [];
666
+ const result = reclaimRuntimePointerFiles([runtimePath(ctx), globalRuntimePath()], {
667
+ dryRun,
668
+ onReclaim: ({ gcCutoff }) => {
669
+ if (Number.isSafeInteger(gcCutoff)) gcCutoffs.push(gcCutoff);
670
+ }
671
+ });
672
+ return { allowed: !result.blocked, gcCutoffs };
673
+ }
674
+
675
+ function requireBufferGcResult(value) {
676
+ const result = {};
677
+ for (const key of ['deleted', 'protected', 'deferred']) {
678
+ const count = Number(value?.[key]);
679
+ if (!Number.isInteger(count) || count < 0) {
680
+ throw new CliError('RUNTIME_BAD_RESPONSE', `Runtime buffer GC returned an invalid ${key} count`);
681
+ }
682
+ result[key] = count;
683
+ }
684
+ return result;
685
+ }
686
+
687
+ function requirePreparedBufferGc(value, { retentionSec, dryRun }) {
688
+ const counts = requireBufferGcResult(value);
689
+ const observedAt = Number(value?.observedAt);
690
+ const cutoffMs = Number(value?.cutoffMs);
691
+ if (!Number.isSafeInteger(observedAt) || observedAt < 0 ||
692
+ !Number.isSafeInteger(cutoffMs) ||
693
+ cutoffMs !== observedAt * 1000 - retentionSec * 1000 ||
694
+ !Array.isArray(value?.gcCutoffs) || value.gcCutoffs.some((cutoff) =>
695
+ !Number.isSafeInteger(cutoff) || cutoff < 0)) {
696
+ throw new CliError('RUNTIME_BAD_RESPONSE', 'Runtime buffer GC returned invalid derived timing');
697
+ }
698
+ if ((!dryRun && (typeof value?.token !== 'string' || value.token.length === 0)) ||
699
+ (dryRun && Object.hasOwn(value || {}, 'token'))) {
700
+ throw new CliError('RUNTIME_BAD_RESPONSE', 'Runtime buffer GC returned an invalid plan token');
701
+ }
702
+ return { ...counts, observedAt, cutoffMs, gcCutoffs: [...value.gcCutoffs], token: value.token || null };
703
+ }
704
+
705
+ function deferredPreparedBufferGc(prepared, graceActive = false) {
706
+ return {
707
+ deleted: 0,
708
+ protected: prepared.protected,
709
+ deferred: prepared.deferred + prepared.deleted,
710
+ complete: false,
711
+ graceActive
712
+ };
713
+ }
714
+
715
+ async function prepareManualBufferGc(ctx, db, { retentionSec, dryRun }) {
716
+ const directory = bufferDirectory(ctx);
717
+ const unknownTracker = new Map();
718
+ const pointerGcCutoffs = [];
719
+ const recoverRuntimePointers = () => {
720
+ const recovery = reclaimUnavailableRuntimePointers(ctx, { dryRun });
721
+ pointerGcCutoffs.push(...recovery.gcCutoffs);
722
+ return recovery.allowed;
723
+ };
724
+ const pointerPresent = runtimePointerPresent(ctx);
725
+ let runtime = null;
726
+ try { runtime = readRuntime(ctx); } catch (error) {
727
+ if (pointerPresent && !recoverRuntimePointers()) {
728
+ const observedAt = now();
729
+ const cutoffMs = observedAt * 1000 - retentionSec * 1000;
730
+ const deferred = requireBufferGcResult(deferEligibleBufferFiles(directory, cutoffMs));
731
+ return { mode: 'unavailable', observedAt, cutoffMs, retentionSec, gcCutoffs: [], token: null, ...deferred };
732
+ }
733
+ if (error?.code !== 'RUNTIME_NOT_RUNNING') throw error;
734
+ }
735
+
736
+ if (runtime) {
737
+ try {
738
+ const prepared = await runtimeRequest(ctx, 'POST', '/api/runtime/gc-buffers', {
739
+ phase: 'prepare',
740
+ retentionSec,
741
+ dryRun
742
+ }, runtime, { timeoutMs: RUNTIME_BUFFER_GC_TIMEOUT_MS });
743
+ return {
744
+ mode: 'runtime',
745
+ runtime,
746
+ retentionSec,
747
+ ...requirePreparedBufferGc(prepared, { retentionSec, dryRun })
748
+ };
749
+ } catch (error) {
750
+ if (runtimeBufferGcUnavailable(error)) {
751
+ if (!recoverRuntimePointers()) {
752
+ const observedAt = now();
753
+ const cutoffMs = observedAt * 1000 - retentionSec * 1000;
754
+ const deferred = requireBufferGcResult(deferEligibleBufferFiles(directory, cutoffMs));
755
+ return { mode: 'unavailable', observedAt, cutoffMs, retentionSec, gcCutoffs: [], token: null, ...deferred };
756
+ }
757
+ runtime = null;
758
+ } else {
759
+ throw error;
760
+ }
761
+ }
762
+ }
763
+
764
+ const observedAt = now();
765
+ const cutoffMs = observedAt * 1000 - retentionSec * 1000;
766
+ const { protectedPaths, unknownPaths, gcCutoffs } = localBufferEvidence(ctx, db, directory, unknownTracker);
767
+ const plan = planBufferFiles({
768
+ directories: [directory],
769
+ cutoffMs,
770
+ protectedPaths,
771
+ unknownPaths,
772
+ evidenceGcCutoffs: gcCutoffs
773
+ });
774
+ return {
775
+ mode: 'local',
776
+ observedAt,
777
+ cutoffMs,
778
+ retentionSec,
779
+ gcCutoffs: [...bufferPlanGcCutoffs(plan, retentionSec), ...pointerGcCutoffs],
780
+ token: null,
781
+ plan,
782
+ directory,
783
+ unknownTracker,
784
+ deleted: plan.deleteEntries.length,
785
+ protected: plan.protectedEntries.length,
786
+ deferred: plan.unknownEntries.length
787
+ };
788
+ }
789
+
790
+ async function applyManualBufferGc(ctx, db, prepared, { dryRun, graceActive }) {
791
+ if (dryRun) {
792
+ return graceActive || prepared.mode === 'unavailable'
793
+ ? deferredPreparedBufferGc(prepared, graceActive)
794
+ : { ...requireBufferGcResult(prepared), complete: true, graceActive: false };
795
+ }
796
+ if (prepared.mode === 'unavailable') return deferredPreparedBufferGc(prepared);
797
+ if (prepared.mode === 'local') {
798
+ if (graceActive) return deferredPreparedBufferGc(prepared, true);
799
+ try {
800
+ return applyClockSafeBufferPlan({
801
+ db,
802
+ plan: prepared.plan,
803
+ retentionSec: prepared.retentionSec,
804
+ collectEvidence: ({ entries }) => localBufferEvidence(
805
+ ctx,
806
+ db,
807
+ prepared.directory,
808
+ prepared.unknownTracker,
809
+ entries.map((entry) => entry.path)
810
+ )
811
+ });
812
+ } catch (error) {
813
+ if (error instanceof CliError) throw error;
814
+ throw clockSafetyUnavailable(error);
815
+ }
816
+ }
817
+ try {
818
+ const applied = await runtimeRequest(ctx, 'POST', '/api/runtime/gc-buffers', {
819
+ phase: 'apply',
820
+ token: prepared.token
821
+ }, prepared.runtime, { timeoutMs: RUNTIME_BUFFER_GC_TIMEOUT_MS });
822
+ const counts = requireBufferGcResult(applied);
823
+ if (typeof applied?.complete !== 'boolean' || typeof applied?.graceActive !== 'boolean') {
824
+ throw new CliError('RUNTIME_BAD_RESPONSE', 'Runtime buffer GC returned invalid completion state');
825
+ }
826
+ return { ...counts, complete: applied.complete, graceActive: applied.graceActive };
827
+ } catch (error) {
828
+ if (runtimeBufferGcUnavailable(error)) return deferredPreparedBufferGc(prepared);
829
+ throw error;
830
+ }
831
+ }
832
+
833
+ async function runManualGc(ctx, db, {
834
+ olderThanDays,
835
+ dryRun,
836
+ history
837
+ }) {
838
+ const retentionSec = olderThanDays * 86400;
839
+ const preparedBuffer = await prepareManualBufferGc(ctx, db, { retentionSec, dryRun });
840
+ const gcNow = preparedBuffer.observedAt;
841
+ const historyCategories = ['events', 'tasks', 'messages', 'handoffs'];
842
+ const historySnapshot = createHistoryGcSnapshot(db, gcNow - retentionSec, {
843
+ categories: historyCategories,
844
+ retentionSec
845
+ });
846
+ return runWithHistoryGcSnapshotCleanupAsync(
847
+ db,
848
+ historySnapshot,
849
+ async () => {
850
+ const observationOptions = {
851
+ bufferGcCutoffs: preparedBuffer.gcCutoffs,
852
+ olderThanDays,
853
+ observedAt: gcNow,
854
+ historyCategories,
855
+ includeStalePeers: true,
856
+ historySnapshot
857
+ };
858
+ const clockObservation = dryRun
859
+ ? previewGcClockSafety(ctx, db, observationOptions)
860
+ : observeGcClockSafety(ctx, db, observationOptions);
861
+ const graceActive = clockGraceSuppressed(gcNow, clockObservation.graceUntil);
862
+ const bufferResult = await applyManualBufferGc(ctx, db, preparedBuffer, { dryRun, graceActive });
863
+ let graceBeforeDatabaseApply = false;
864
+ if (!dryRun) {
865
+ try {
866
+ const finalObservedAt = now();
867
+ graceBeforeDatabaseApply = clockGraceSuppressed(
868
+ finalObservedAt,
869
+ readClockGraceUntil(db)
870
+ );
871
+ } catch (error) {
872
+ throw clockSafetyUnavailable(error);
873
+ }
874
+ }
875
+ const forceDefer = graceActive || (!dryRun && !bufferResult.complete) ||
876
+ preparedBuffer.mode === 'unavailable' || graceBeforeDatabaseApply;
877
+ const results = runGcWithHistorySnapshot(ctx, db, {
878
+ olderThanDays,
879
+ dryRun,
880
+ auto: false,
881
+ applyHistory: history,
882
+ protectedBufFiles: null,
883
+ bufferCutoffMs: preparedBuffer.cutoffMs,
884
+ skipBufferFiles: true,
885
+ bufferGcCutoffs: preparedBuffer.gcCutoffs,
886
+ historyCategories,
887
+ gcNow,
888
+ historySnapshot,
889
+ clockObservation,
890
+ forceDefer
891
+ });
892
+ results.buf_files = bufferResult.deleted;
893
+ results.protected_buf_files = bufferResult.protected;
894
+ results.deferred_buf_files = bufferResult.deferred;
895
+ return results;
896
+ },
897
+ 'Manual GC run'
898
+ );
899
+ }
900
+
901
+ function gcRetentionDays(opts) {
902
+ const raw = opts['older-than'];
903
+ if (raw === undefined) return 7;
904
+ if (typeof raw !== 'string' || !/^(?:0|[1-9][0-9]*)$/.test(raw)) {
905
+ throw new CliError('BAD_ARGS', '--older-than must be a canonical non-negative integer');
906
+ }
907
+ const value = Number(raw);
908
+ if (!Number.isSafeInteger(value) || !Number.isSafeInteger(value * 86400000)) {
909
+ throw new CliError('BAD_ARGS', '--older-than exceeds the safe integer range');
910
+ }
911
+ return value;
912
+ }
913
+
914
+ async function cmdGc(ctx, args) {
915
+ if (wantsHelp(args)) return helpGc();
916
+ const valuedDestructiveFlag = args.find((arg) =>
917
+ typeof arg === 'string' && (/^--yes=/.test(arg) || /^--history=/.test(arg)));
918
+ if (valuedDestructiveFlag) {
919
+ throw new CliError('BAD_ARGS', `${valuedDestructiveFlag.split('=', 1)[0]} does not accept a value`);
920
+ }
921
+ const opts = parseOpts(args, { booleans: ['yes', 'force', 'history'] });
922
+ validateOpts('gc', opts, ['older-than', 'yes', 'history']);
923
+ if (opts.yes !== undefined && opts.yes !== true) {
924
+ throw new CliError('BAD_ARGS', '--yes does not accept a value');
925
+ }
926
+ if (opts.history !== undefined && opts.history !== true) {
927
+ throw new CliError('BAD_ARGS', '--history does not accept a value');
928
+ }
929
+ const olderThanDays = gcRetentionDays(opts);
930
+ const dryRun = opts.yes !== true;
931
+ const db = connect(ctx);
932
+ let results;
933
+ try {
934
+ results = await runManualGc(ctx, db, {
935
+ olderThanDays,
936
+ dryRun,
937
+ history: opts.history === true
938
+ });
939
+ const appliedDatabaseRows = results.stale_peers + results.expired_locks +
940
+ results.old_events + results.old_tasks + results.old_messages + results.old_handoffs;
941
+ if (!dryRun && appliedDatabaseRows > 0) {
942
+ // Reclaim WAL after deleting many rows (conc-05). Best-effort: may be busy
943
+ // with other connections; the result is reported but never blocks.
944
+ try {
945
+ const cp = db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
946
+ if (cp) results.wal_checkpoint = cp;
947
+ } catch {}
948
+ }
949
+ } finally {
950
+ db.close();
951
+ }
952
+
953
+ printResult(ctx, results, (r) => {
954
+ const lines = [`gc completed${dryRun ? ' (dry-run, add --yes to apply)' : ''}:`];
955
+ if (r.buf_files) lines.push(` buffer files: ${r.buf_files}`);
956
+ if (r.protected_buf_files) lines.push(` protected buffer files: ${r.protected_buf_files}`);
957
+ if (r.deferred_buf_files) lines.push(` buffer files deferred: ${r.deferred_buf_files}`);
958
+ if (r.stale_peers) lines.push(` stale peers: ${r.stale_peers}`);
959
+ if (r.protected_stale_peers) lines.push(` protected stale peers: ${r.protected_stale_peers}`);
960
+ if (r.deferred_stale_peers) lines.push(` stale peers deferred: ${r.deferred_stale_peers}`);
961
+ if (r.deferred_unknown_peers) lines.push(` unknown peers deferred: ${r.deferred_unknown_peers}`);
962
+ if (r.deferred_history) lines.push(` history rows deferred: ${r.deferred_history}`);
963
+ if (r.deferred_age_based) lines.push(' age-based cleanup deferred by clock grace');
964
+ if (r.old_events) lines.push(` old events: ${r.old_events}`);
965
+ if (r.old_tasks) lines.push(` old tasks: ${r.old_tasks}`);
966
+ if (r.old_messages) lines.push(` old messages: ${r.old_messages}`);
967
+ if (r.old_handoffs) lines.push(` old handoffs: ${r.old_handoffs}`);
968
+ if (r.protected_old_events) lines.push(` protected old events: ${r.protected_old_events}`);
969
+ if (r.protected_old_tasks) lines.push(` protected old tasks: ${r.protected_old_tasks}`);
970
+ if (r.protected_old_messages) lines.push(` protected old messages: ${r.protected_old_messages}`);
971
+ if (r.protected_old_handoffs) lines.push(` protected old handoffs: ${r.protected_old_handoffs}`);
972
+ if (r.deferred_old_events) lines.push(` old events deferred: ${r.deferred_old_events}`);
973
+ if (r.deferred_old_tasks) lines.push(` old tasks deferred: ${r.deferred_old_tasks}`);
974
+ if (r.deferred_old_messages) lines.push(` old messages deferred: ${r.deferred_old_messages}`);
975
+ if (r.deferred_old_handoffs) lines.push(` old handoffs deferred: ${r.deferred_old_handoffs}`);
976
+ if (r.expired_locks) lines.push(` expired locks: ${r.expired_locks}`);
977
+ if (r.protected_expired_locks) lines.push(` protected expired locks: ${r.protected_expired_locks}`);
978
+ if (r.deferred_expired_locks) lines.push(` expired locks deferred by clock grace: ${r.deferred_expired_locks}`);
979
+ if (r.wal_checkpoint) lines.push(` wal checkpoint: ${r.wal_checkpoint.busy ? 'busy' : 'ok'} (log ${r.wal_checkpoint.log}, checkpointed ${r.wal_checkpoint.checkpointed})`);
980
+ if (!r.buf_files && !r.protected_buf_files && !r.deferred_buf_files && !r.stale_peers &&
981
+ !r.protected_stale_peers && !r.deferred_stale_peers && !r.old_events && !r.old_tasks &&
982
+ !r.old_messages && !r.old_handoffs && !r.expired_locks && !r.deferred_expired_locks &&
983
+ !r.protected_expired_locks && !r.deferred_unknown_peers && !r.deferred_history &&
984
+ !r.protected_old_events && !r.protected_old_tasks && !r.protected_old_messages &&
985
+ !r.protected_old_handoffs && !r.deferred_age_based) {
986
+ lines.push(' nothing to clean');
987
+ }
988
+ // v1-06: warn when history tables are retained so the user knows to add
989
+ // --history for events/tasks/messages/handoffs pruning.
990
+ if (!dryRun && opts.history !== true) {
991
+ lines.push(' (history tables retained; add --history to prune events/tasks/messages/handoffs)');
992
+ }
993
+ return lines.join('\n');
994
+ });
995
+ }
996
+
997
+ return {
998
+ cmdGc, runGc, runGcWithHistorySnapshot, bufferDirectory,
999
+ observeGcClockSafety, previewGcClockSafety, runManualGc,
1000
+ prepareManualBufferGc, applyManualBufferGc, gcRetentionDays
1001
+ };
1002
+ }