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