@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +26 -4
  3. package/README.zh-CN.md +22 -3
  4. package/bin/hcc.mjs +3 -5622
  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 +16 -5
  10. package/docs/commands.zh-CN.md +16 -5
  11. package/docs/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +53 -5
  14. package/docs/guide.zh-CN.md +46 -3
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +15 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +139 -15
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +61 -8
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +232 -29
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +143 -7
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. package/scripts/regression.mjs +5646 -262
@@ -0,0 +1,336 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { performance } from 'node:perf_hooks';
4
+
5
+ import { resolvePeerEvidence } from '../core/peers/evidence.mjs';
6
+ import { inspectProcessIdentity } from '../process/identity.mjs';
7
+
8
+ function canonicalDirectories(values) {
9
+ const directories = [];
10
+ const seen = new Set();
11
+ for (const value of values || []) {
12
+ if (typeof value !== 'string' || value.length === 0) continue;
13
+ try {
14
+ const resolved = path.resolve(value);
15
+ const supplied = fs.lstatSync(resolved);
16
+ if (!supplied.isDirectory() || supplied.isSymbolicLink()) continue;
17
+ const canonical = fs.realpathSync.native(resolved);
18
+ const stat = fs.lstatSync(canonical);
19
+ if (!stat.isDirectory() || stat.isSymbolicLink() ||
20
+ stat.dev !== supplied.dev || stat.ino !== supplied.ino || seen.has(canonical)) continue;
21
+ seen.add(canonical);
22
+ directories.push(canonical);
23
+ } catch {}
24
+ }
25
+ return directories;
26
+ }
27
+
28
+ function directoryPaths(directory) {
29
+ try {
30
+ return fs.readdirSync(directory).map((name) => path.resolve(directory, name));
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ export function externalBufferSessionIds(directory) {
37
+ try {
38
+ return fs.readdirSync(directory)
39
+ .filter((name) => name.endsWith('.out'))
40
+ .map((name) => path.basename(name, '.out'))
41
+ .sort();
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ function candidateSetForDirectories(candidatePaths, directorySet) {
48
+ if (candidatePaths === null || candidatePaths === undefined) return null;
49
+ const candidates = new Set();
50
+ for (const file of candidatePaths || []) {
51
+ if (typeof file !== 'string' || file.length === 0) continue;
52
+ const resolved = path.resolve(file);
53
+ if (directorySet.has(path.dirname(resolved))) candidates.add(resolved);
54
+ }
55
+ return candidates;
56
+ }
57
+
58
+ function candidateFiles(directory, candidateSet) {
59
+ if (candidateSet === null) return directoryPaths(directory);
60
+ return [...candidateSet].filter((file) => path.dirname(file) === directory);
61
+ }
62
+
63
+ function externalMetadataFiles(directory, candidateSet) {
64
+ if (candidateSet === null) {
65
+ return directoryPaths(directory).filter((file) => file.endsWith('.meta'));
66
+ }
67
+ const files = new Set();
68
+ for (const file of candidateFiles(directory, candidateSet)) {
69
+ const match = path.basename(file).match(/^(.*)\.(?:out|in|resize|meta)$/);
70
+ if (match) files.add(path.resolve(directory, `${match[1]}.meta`));
71
+ }
72
+ return [...files];
73
+ }
74
+
75
+ function addExternalGroup(target, directory, id) {
76
+ for (const suffix of ['out', 'in', 'resize', 'meta']) {
77
+ target.add(path.resolve(directory, `${id}.${suffix}`));
78
+ }
79
+ }
80
+
81
+ export function externalBufferEvidence(meta, inspectProcess = inspectProcessIdentity) {
82
+ const processes = [];
83
+ const wrapperPid = meta?.wrapper_pid || meta?.wrapperPid || null;
84
+ const childPid = meta?.pid || null;
85
+ if (wrapperPid) processes.push({
86
+ name: 'wrapper',
87
+ storedIdentity: meta?.wrapper_identity || meta?.wrapperIdentity || null,
88
+ current: inspectProcess(wrapperPid)
89
+ });
90
+ if (childPid) processes.push({
91
+ name: 'child',
92
+ storedIdentity: meta?.child_identity || meta?.childIdentity || null,
93
+ current: inspectProcess(childPid)
94
+ });
95
+ return resolvePeerEvidence({ peer: { status: 'running' }, processes });
96
+ }
97
+
98
+ export function externalBufferOwnerKey(meta) {
99
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null;
100
+ const generation = typeof meta.generation === 'string' ? meta.generation.trim() : '';
101
+ if (generation) return `generation:${generation}`;
102
+ const wrapperPid = meta.wrapper_pid || meta.wrapperPid || null;
103
+ const childPid = meta.pid || null;
104
+ if (!wrapperPid && !childPid) return null;
105
+ const identityTuple = (pid, identity) => ({
106
+ pid: pid === null || pid === undefined ? null : String(pid),
107
+ startToken: identity?.startToken || null,
108
+ commandHash: identity?.commandHash || null
109
+ });
110
+ return JSON.stringify({
111
+ wrapper: identityTuple(wrapperPid, meta.wrapper_identity || meta.wrapperIdentity || null),
112
+ child: identityTuple(childPid, meta.child_identity || meta.childIdentity || null)
113
+ });
114
+ }
115
+
116
+ export function readExternalBufferMetadata(file) {
117
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
118
+ const fd = fs.openSync(file, flags);
119
+ try {
120
+ const before = fs.fstatSync(fd);
121
+ if (!before.isFile()) throw new Error('metadata is not a regular file');
122
+ const value = JSON.parse(fs.readFileSync(fd, 'utf8'));
123
+ const after = fs.fstatSync(fd);
124
+ if (before.dev !== after.dev || before.ino !== after.ino ||
125
+ before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
126
+ throw new Error('metadata changed while being read');
127
+ }
128
+ return value;
129
+ } finally {
130
+ fs.closeSync(fd);
131
+ }
132
+ }
133
+
134
+ function safeTmuxPath(directory, row, runtimeId) {
135
+ const safePane = String(row.runtime_target || '').replace(/[^A-Za-z0-9_-]/g, '');
136
+ const safeId = String(runtimeId || '').replace(/[^A-Za-z0-9_.-]/g, '_');
137
+ if (!safePane || !safeId) return null;
138
+ return path.resolve(directory, `tmux-${safePane}-${safeId}.pipe`);
139
+ }
140
+
141
+ function contextBufferDirectory(ctx) {
142
+ return path.resolve(ctx.root, '.hello-cc', 'bufs');
143
+ }
144
+
145
+ function boundedUnknown(referenceFile, {
146
+ nowMs,
147
+ monotonicNowMs,
148
+ unknownGraceMs,
149
+ unknownTracker,
150
+ gcCutoffs
151
+ }) {
152
+ let stat;
153
+ try { stat = fs.lstatSync(referenceFile); } catch { return false; }
154
+ if (stat.isSymbolicLink()) return false;
155
+ const identity = `${stat.dev}:${stat.ino}`;
156
+ const timestampMs = Math.floor(Math.max(Number(stat.mtimeMs), Number(stat.ctimeMs)));
157
+ let cutoffMs = timestampMs + unknownGraceMs;
158
+ if (unknownTracker) {
159
+ const current = monotonicNowMs();
160
+ if (!Number.isFinite(current) || current < 0) {
161
+ throw new TypeError('monotonicNowMs must return a non-negative finite number');
162
+ }
163
+ const prior = unknownTracker.get(referenceFile);
164
+ let sinceMonotonicMs;
165
+ if (prior?.identity === identity && prior.state === 'unknown' &&
166
+ Number.isFinite(prior.sinceMonotonicMs)) {
167
+ sinceMonotonicMs = prior.sinceMonotonicMs;
168
+ if (Number.isFinite(prior.cutoffMs)) cutoffMs = prior.cutoffMs;
169
+ } else {
170
+ const wallNow = nowMs();
171
+ if (!Number.isFinite(wallNow)) throw new TypeError('nowMs must return a finite number');
172
+ const persistedAgeMs = wallNow < timestampMs
173
+ ? unknownGraceMs
174
+ : Math.min(unknownGraceMs, Math.max(0, wallNow - timestampMs));
175
+ const knownTransition = prior?.identity === identity;
176
+ sinceMonotonicMs = knownTransition ? current : current - persistedAgeMs;
177
+ cutoffMs = knownTransition ? wallNow + unknownGraceMs : timestampMs + unknownGraceMs;
178
+ unknownTracker.set(referenceFile, { state: 'unknown', sinceMonotonicMs, cutoffMs, identity });
179
+ }
180
+ const cutoffSec = Math.max(0, Math.ceil(cutoffMs / 1000));
181
+ if (Number.isSafeInteger(cutoffSec)) gcCutoffs.add(cutoffSec);
182
+ const elapsedMs = current < sinceMonotonicMs
183
+ ? unknownGraceMs
184
+ : current - sinceMonotonicMs;
185
+ return elapsedMs < unknownGraceMs;
186
+ }
187
+ const current = nowMs();
188
+ if (!Number.isFinite(current)) throw new TypeError('nowMs must return a finite number');
189
+ const cutoffSec = Math.max(0, Math.ceil(cutoffMs / 1000));
190
+ if (Number.isSafeInteger(cutoffSec)) gcCutoffs.add(cutoffSec);
191
+ const elapsedMs = current < timestampMs ? unknownGraceMs : current - timestampMs;
192
+ return elapsedMs < unknownGraceMs;
193
+ }
194
+
195
+ function recordKnownEvidence(unknownTracker, file, state, monotonicNowMs) {
196
+ if (!unknownTracker) return;
197
+ let stat;
198
+ try { stat = fs.lstatSync(file); } catch { unknownTracker.delete(file); return; }
199
+ const observedMonotonicMs = monotonicNowMs();
200
+ if (!Number.isFinite(observedMonotonicMs) || observedMonotonicMs < 0) {
201
+ throw new TypeError('monotonicNowMs must return a non-negative finite number');
202
+ }
203
+ unknownTracker.set(file, {
204
+ state,
205
+ observedMonotonicMs,
206
+ identity: `${stat.dev}:${stat.ino}`
207
+ });
208
+ }
209
+
210
+ export function collectBufferEvidence({
211
+ directories = [],
212
+ projectDbs = [],
213
+ sessions = [],
214
+ inspectProcess = inspectProcessIdentity,
215
+ observePeer = null,
216
+ nowMs = Date.now,
217
+ monotonicNowMs = () => performance.now(),
218
+ unknownGraceMs = 120_000,
219
+ unknownTracker = null,
220
+ candidatePaths = null
221
+ } = {}) {
222
+ if (!Number.isFinite(unknownGraceMs) || unknownGraceMs < 0) {
223
+ throw new TypeError('unknownGraceMs must be a non-negative finite number');
224
+ }
225
+ if (unknownTracker !== null && !(unknownTracker instanceof Map)) {
226
+ throw new TypeError('unknownTracker must be a Map');
227
+ }
228
+ const canonical = canonicalDirectories(directories);
229
+ const directorySet = new Set(canonical);
230
+ const candidateSet = candidateSetForDirectories(candidatePaths, directorySet);
231
+ const relevant = (file) => candidateSet === null || candidateSet.has(path.resolve(file));
232
+ const protectedPaths = new Set();
233
+ const unknownPaths = new Set();
234
+ const gcCutoffs = new Set();
235
+
236
+ for (const session of sessions || []) {
237
+ if (session?.status !== 'running' || session.type === 'external') continue;
238
+ for (const file of [session.outFile, session.inFile, session.resizeFile, session.pipeFile, session.metaFile]) {
239
+ if (typeof file === 'string' && file.length > 0) {
240
+ const resolved = path.resolve(file);
241
+ if (!relevant(resolved)) continue;
242
+ protectedPaths.add(resolved);
243
+ recordKnownEvidence(unknownTracker, resolved, 'live', monotonicNowMs);
244
+ }
245
+ }
246
+ }
247
+
248
+ for (const directory of canonical) {
249
+ for (const file of externalMetadataFiles(directory, candidateSet)) {
250
+ const id = path.basename(file, '.meta');
251
+ let meta;
252
+ try {
253
+ meta = readExternalBufferMetadata(file);
254
+ } catch {
255
+ if (boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
256
+ addExternalGroup(unknownPaths, directory, id);
257
+ }
258
+ continue;
259
+ }
260
+ const evidence = externalBufferEvidence(meta, inspectProcess);
261
+ if (evidence.state === 'live') {
262
+ recordKnownEvidence(unknownTracker, file, 'live', monotonicNowMs);
263
+ addExternalGroup(protectedPaths, directory, id);
264
+ } else if (evidence.state === 'unknown' &&
265
+ boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
266
+ addExternalGroup(unknownPaths, directory, id);
267
+ } else if (evidence.state === 'dead') {
268
+ recordKnownEvidence(unknownTracker, file, 'dead', monotonicNowMs);
269
+ }
270
+ }
271
+ }
272
+
273
+ const knownTmuxPaths = new Set();
274
+ for (const entry of projectDbs || []) {
275
+ const projectDirectory = contextBufferDirectory(entry.ctx);
276
+ let canonicalProjectDirectory;
277
+ try { canonicalProjectDirectory = fs.realpathSync.native(projectDirectory); } catch { continue; }
278
+ if (!directorySet.has(canonicalProjectDirectory)) continue;
279
+ let rows;
280
+ try {
281
+ rows = entry.db.prepare(`
282
+ SELECT p.id, p.status, p.pid, p.pid_start_token, p.pid_command_hash,
283
+ b.transport, b.runtime_session_id, b.runtime_target
284
+ FROM peer_bindings b
285
+ JOIN peers p ON p.id = b.peer
286
+ WHERE b.transport = 'tmux' AND b.runtime_target IS NOT NULL
287
+ `).all();
288
+ } catch {
289
+ continue;
290
+ }
291
+ for (const row of rows) {
292
+ const runtimeIds = new Set([row.runtime_session_id, row.id].filter(Boolean));
293
+ const files = [...runtimeIds]
294
+ .map((runtimeId) => safeTmuxPath(canonicalProjectDirectory, row, runtimeId))
295
+ .filter((file) => file && relevant(file));
296
+ if (!files.length) continue;
297
+ const evidence = observePeer
298
+ ? observePeer(entry.ctx, row, row)
299
+ : { state: 'unknown', reason: 'tmux_probe_missing' };
300
+ for (const file of files) {
301
+ knownTmuxPaths.add(file);
302
+ if (evidence.state === 'live') {
303
+ recordKnownEvidence(unknownTracker, file, 'live', monotonicNowMs);
304
+ protectedPaths.add(file);
305
+ } else if (evidence.state === 'unknown' &&
306
+ boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
307
+ unknownPaths.add(file);
308
+ } else if (evidence.state === 'dead') {
309
+ recordKnownEvidence(unknownTracker, file, 'dead', monotonicNowMs);
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ for (const directory of canonical) {
316
+ for (const file of candidateFiles(directory, candidateSet)) {
317
+ if (!file.endsWith('.pipe') || knownTmuxPaths.has(file)) continue;
318
+ try {
319
+ if (fs.lstatSync(file).isFIFO() &&
320
+ boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
321
+ unknownPaths.add(file);
322
+ }
323
+ } catch {}
324
+ }
325
+ }
326
+
327
+ if (unknownTracker) {
328
+ for (const file of unknownTracker.keys()) {
329
+ if (!relevant(file)) continue;
330
+ try { fs.lstatSync(file); } catch { unknownTracker.delete(file); }
331
+ }
332
+ }
333
+
334
+ for (const file of protectedPaths) unknownPaths.delete(file);
335
+ return { protectedPaths, unknownPaths, gcCutoffs: [...gcCutoffs].sort((left, right) => left - right) };
336
+ }
@@ -0,0 +1,167 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import path from 'node:path';
3
+
4
+ import { observeClockSafetyInTransaction } from '../core/coordination/clock-safety.mjs';
5
+ import { tx } from '../db/schema.mjs';
6
+ import { clockGraceSuppressed } from '../shared/clock-grace.mjs';
7
+ import { CliError } from '../shared/errors.mjs';
8
+ import { withBufferDirectoryLeases } from './buffer-directory-lease.mjs';
9
+ import {
10
+ applyBufferPlan,
11
+ bufferPlanGcCutoffs,
12
+ refreshBufferPlanEvidence
13
+ } from './buffer-gc.mjs';
14
+
15
+ export const BUFFER_GC_APPLY_BATCH_SIZE = 64;
16
+ export const BUFFER_GC_PLAN_TTL_MS = 15_000;
17
+
18
+ function boundPath(value, label) {
19
+ if (typeof value !== 'string' || value.length === 0) {
20
+ throw new TypeError(`${label} must be a non-empty path`);
21
+ }
22
+ return path.resolve(value);
23
+ }
24
+
25
+ export function createBufferGcPlanStore(options = {}) {
26
+ const nowMs = options.nowMs || Date.now;
27
+ const ttlMs = options.ttlMs ?? BUFFER_GC_PLAN_TTL_MS;
28
+ const makeToken = options.makeToken || (() => randomBytes(32).toString('base64url'));
29
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
30
+ throw new TypeError('buffer GC token TTL must be a positive safe integer');
31
+ }
32
+ const plans = new Map();
33
+
34
+ const deletePlan = (token) => {
35
+ const record = plans.get(token);
36
+ plans.delete(token);
37
+ if (record?.expiryTimer) clearTimeout(record.expiryTimer);
38
+ return record;
39
+ };
40
+
41
+ const cleanupExpired = () => {
42
+ const current = nowMs();
43
+ for (const [token, record] of plans) {
44
+ if (record.expiresAt <= current) deletePlan(token);
45
+ }
46
+ };
47
+
48
+ return {
49
+ prepare(record) {
50
+ cleanupExpired();
51
+ const root = boundPath(record?.root, 'root');
52
+ const dbPath = boundPath(record?.dbPath, 'dbPath');
53
+ if (!record?.plan || !Array.isArray(record.plan.deleteEntries)) {
54
+ throw new TypeError('valid buffer GC plan required');
55
+ }
56
+ let token;
57
+ do { token = makeToken(); } while (typeof token === 'string' && plans.has(token));
58
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43,}$/.test(token)) {
59
+ throw new TypeError('buffer GC token source returned an invalid token');
60
+ }
61
+ const stored = {
62
+ ...record,
63
+ root,
64
+ dbPath,
65
+ expiresAt: nowMs() + ttlMs
66
+ };
67
+ stored.expiryTimer = setTimeout(() => deletePlan(token), ttlMs);
68
+ stored.expiryTimer.unref?.();
69
+ plans.set(token, stored);
70
+ return token;
71
+ },
72
+
73
+ take({ token, root, dbPath } = {}) {
74
+ if (typeof token !== 'string' || token.length === 0) {
75
+ throw new CliError('RUNTIME_GC_TOKEN_INVALID', 'Runtime buffer GC token is invalid');
76
+ }
77
+ const record = plans.get(token);
78
+ if (!record) {
79
+ cleanupExpired();
80
+ throw new CliError('RUNTIME_GC_TOKEN_INVALID', 'Runtime buffer GC token is invalid or already used');
81
+ }
82
+ deletePlan(token);
83
+ if (record.expiresAt <= nowMs()) {
84
+ throw new CliError('RUNTIME_GC_TOKEN_EXPIRED', 'Runtime buffer GC token expired');
85
+ }
86
+ if (record.root !== boundPath(root, 'root') || record.dbPath !== boundPath(dbPath, 'dbPath')) {
87
+ throw new CliError('RUNTIME_GC_TOKEN_BINDING', 'Runtime buffer GC token binding does not match the request');
88
+ }
89
+ cleanupExpired();
90
+ return record;
91
+ },
92
+
93
+ cleanupExpired,
94
+ pendingCount() {
95
+ cleanupExpired();
96
+ return plans.size;
97
+ }
98
+ };
99
+ }
100
+
101
+ export function applyClockSafeBufferPlan({
102
+ db,
103
+ plan,
104
+ retentionSec,
105
+ nowSec = () => Math.floor(Date.now() / 1000),
106
+ batchSize = BUFFER_GC_APPLY_BATCH_SIZE,
107
+ afterBatch = null,
108
+ collectEvidence = null
109
+ } = {}) {
110
+ if (!db || !plan || !Array.isArray(plan.deleteEntries)) {
111
+ throw new TypeError('database and valid buffer GC plan required');
112
+ }
113
+ if (!Number.isSafeInteger(batchSize) || batchSize <= 0 || batchSize > BUFFER_GC_APPLY_BATCH_SIZE) {
114
+ throw new TypeError(`buffer GC batch size must be between 1 and ${BUFFER_GC_APPLY_BATCH_SIZE}`);
115
+ }
116
+ const result = {
117
+ deleted: 0,
118
+ protected: plan.protectedEntries?.length || 0,
119
+ deferred: plan.unknownEntries?.length || 0,
120
+ complete: true,
121
+ graceActive: false
122
+ };
123
+
124
+ for (let offset = 0; offset < plan.deleteEntries.length; offset += batchSize) {
125
+ const entries = plan.deleteEntries.slice(offset, offset + batchSize);
126
+ const batch = withBufferDirectoryLeases(
127
+ entries.map((entry) => entry.directory.path),
128
+ () => {
129
+ const evidence = collectEvidence
130
+ ? collectEvidence({ offset, entries: [...entries], plan })
131
+ : null;
132
+ const refreshed = evidence
133
+ ? refreshBufferPlanEvidence({ ...plan, deleteEntries: entries }, evidence)
134
+ : { ...plan, protectedEntries: [], unknownEntries: [], deleteEntries: entries };
135
+ const applied = tx(db, () => {
136
+ const observedAt = nowSec();
137
+ if (!Number.isSafeInteger(observedAt) || observedAt < 0) {
138
+ throw new TypeError('nowSec must return a non-negative safe integer');
139
+ }
140
+ const observation = observeClockSafetyInTransaction(db, {
141
+ operation: 'gc',
142
+ gcCutoffs: bufferPlanGcCutoffs(refreshed, retentionSec),
143
+ nowSec: observedAt
144
+ });
145
+ if (clockGraceSuppressed(observedAt, observation.graceUntil)) return null;
146
+ return applyBufferPlan(refreshed);
147
+ });
148
+ return { applied, refreshed };
149
+ }
150
+ );
151
+ if (!batch.applied) {
152
+ result.protected += batch.refreshed.protectedEntries.length;
153
+ result.deferred += batch.refreshed.unknownEntries.length +
154
+ batch.refreshed.deleteEntries.length +
155
+ Math.max(0, plan.deleteEntries.length - offset - entries.length);
156
+ result.complete = false;
157
+ result.graceActive = true;
158
+ break;
159
+ }
160
+ result.deleted += batch.applied.deleted;
161
+ result.protected += batch.refreshed.protectedEntries.length;
162
+ result.deferred += batch.applied.deferred;
163
+ if (batch.applied.deferred > 0) result.complete = false;
164
+ if (afterBatch) afterBatch({ offset, size: entries.length, result: { ...result } });
165
+ }
166
+ return result;
167
+ }