@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
@@ -1,15 +1,198 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
+ import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
4
5
  import { CliError } from '../shared/errors.mjs';
6
+ import { withFileLock } from '../shared/file-lock.mjs';
7
+ import { writeJsonSafe } from '../shared/json-file.mjs';
5
8
  import {
6
9
  globalRuntimePath,
7
10
  runtimePath
8
11
  } from './paths.mjs';
9
- import { runtimeApiUrl } from '../web/runtime.mjs';
12
+ import { runtimeHttpRequest } from '../web/runtime.mjs';
13
+ import { API_VERSION } from '../web/api-version.mjs';
10
14
 
11
15
  const DEFAULT_PRODUCT_NAME = 'hello-cc';
12
16
  const DEFAULT_CLI_NAME = 'hcc';
17
+ export const RUNTIME_POINTER_UNKNOWN_GRACE_MS = 120_000;
18
+
19
+ function completeProcessIdentity(value) {
20
+ return compareProcessIdentity(value, value) === 'live';
21
+ }
22
+
23
+ export function runtimeProcessIdentity({
24
+ pid = process.pid,
25
+ inspect = inspectProcessIdentity
26
+ } = {}) {
27
+ const observed = inspect(pid);
28
+ return observed?.state === 'live' && completeProcessIdentity(observed.identity)
29
+ ? observed.identity
30
+ : null;
31
+ }
32
+
33
+ export function classifyRuntimePointer(runtime, {
34
+ inspect = inspectProcessIdentity,
35
+ ageMs = 0,
36
+ unknownGraceMs = RUNTIME_POINTER_UNKNOWN_GRACE_MS
37
+ } = {}) {
38
+ if (!Number.isFinite(ageMs) || ageMs < 0 ||
39
+ !Number.isFinite(unknownGraceMs) || unknownGraceMs < 0) {
40
+ throw new TypeError('runtime pointer ages must be non-negative finite numbers');
41
+ }
42
+ const pid = Number(runtime?.pid);
43
+ const storedEvidence = runtimeIdentityEvidence(runtime);
44
+ const stored = storedEvidence.present ? storedEvidence.identity : null;
45
+ if (!Number.isInteger(pid) || pid <= 0 || !completeProcessIdentity(stored) || stored.pid !== pid) {
46
+ return { state: 'unknown', reclaimable: ageMs >= unknownGraceMs };
47
+ }
48
+ const current = inspect(pid);
49
+ if (current?.state === 'dead') return { state: 'dead', reclaimable: true };
50
+ if (current?.state !== 'live' || !completeProcessIdentity(current.identity)) {
51
+ return { state: 'unknown', reclaimable: ageMs >= unknownGraceMs };
52
+ }
53
+ return compareProcessIdentity(stored, current.identity) === 'live'
54
+ ? { state: 'alive', reclaimable: false }
55
+ : { state: 'dead', reclaimable: true };
56
+ }
57
+
58
+ function runtimePointerAge(stat, currentMs, unknownGraceMs) {
59
+ const timestampMs = Math.floor(Math.max(Number(stat.mtimeMs), Number(stat.ctimeMs)));
60
+ if (!Number.isFinite(currentMs) || !Number.isFinite(timestampMs)) {
61
+ throw new TypeError('runtime pointer timestamps must be finite numbers');
62
+ }
63
+ return {
64
+ ageMs: currentMs < timestampMs ? 0 : currentMs - timestampMs,
65
+ cutoffSec: Math.max(0, Math.floor(timestampMs / 1000) + Math.ceil(unknownGraceMs / 1000))
66
+ };
67
+ }
68
+
69
+ export function reclaimRuntimePointerFiles(files, {
70
+ nowMs = Date.now,
71
+ inspect = inspectProcessIdentity,
72
+ unknownGraceMs = RUNTIME_POINTER_UNKNOWN_GRACE_MS,
73
+ reclaimUnknown = true,
74
+ dryRun = false,
75
+ onReclaim = null,
76
+ withLock = withFileLock
77
+ } = {}) {
78
+ if (typeof reclaimUnknown !== 'boolean') {
79
+ throw new TypeError('reclaimUnknown must be a boolean');
80
+ }
81
+ let blocked = false;
82
+ let reclaimed = 0;
83
+ const outcomes = [];
84
+ for (const suppliedFile of new Set((files || []).map((file) => path.resolve(file)))) {
85
+ if (!fs.existsSync(suppliedFile)) continue;
86
+ let recorded = false;
87
+ const record = (outcome) => {
88
+ recorded = true;
89
+ outcomes.push({ file: suppliedFile, ...outcome });
90
+ };
91
+ try {
92
+ withLock(suppliedFile, (file) => {
93
+ if (!fs.existsSync(file)) {
94
+ record({ state: 'missing', action: 'skipped' });
95
+ return;
96
+ }
97
+ const stat = fs.lstatSync(file);
98
+ if (stat.isSymbolicLink() || !stat.isFile()) {
99
+ blocked = true;
100
+ record({ state: 'unknown', action: 'blocked', reason: 'invalid_pointer' });
101
+ return;
102
+ }
103
+ const raw = fs.readFileSync(file, 'utf8');
104
+ let runtime = null;
105
+ try { runtime = JSON.parse(raw); } catch {}
106
+ const timing = runtimePointerAge(stat, nowMs(), unknownGraceMs);
107
+ const evidence = classifyRuntimePointer(runtime, {
108
+ inspect,
109
+ ageMs: timing.ageMs,
110
+ unknownGraceMs
111
+ });
112
+ if (!evidence.reclaimable || (evidence.state === 'unknown' && !reclaimUnknown)) {
113
+ blocked = true;
114
+ record({ state: evidence.state, action: 'blocked', reason: 'owner_not_confirmed_dead' });
115
+ return;
116
+ }
117
+ const current = fs.lstatSync(file);
118
+ const currentRaw = fs.readFileSync(file, 'utf8');
119
+ if (current.isSymbolicLink() || !current.isFile() ||
120
+ current.dev !== stat.dev || current.ino !== stat.ino ||
121
+ current.size !== stat.size || current.mtimeMs !== stat.mtimeMs ||
122
+ current.ctimeMs !== stat.ctimeMs || currentRaw !== raw) {
123
+ blocked = true;
124
+ record({ state: 'unknown', action: 'blocked', reason: 'pointer_changed' });
125
+ return;
126
+ }
127
+ if (!dryRun) fs.unlinkSync(file);
128
+ reclaimed += 1;
129
+ record({ state: evidence.state, action: dryRun ? 'would-reclaim' : 'reclaimed' });
130
+ onReclaim?.({
131
+ file,
132
+ state: evidence.state,
133
+ gcCutoff: evidence.state === 'unknown' ? timing.cutoffSec : null
134
+ });
135
+ });
136
+ } catch {
137
+ blocked = true;
138
+ if (!recorded) record({ state: 'unknown', action: 'blocked', reason: 'lock_unavailable' });
139
+ }
140
+ }
141
+ return { blocked, reclaimed, outcomes };
142
+ }
143
+
144
+ // A runtime pointer carries the runtime process pid. If that pid is no longer
145
+ // alive, the pointer is stale and must not be returned as a reachable runtime
146
+ // (bg-01): otherwise commands and shims fail against a dead runtime until
147
+ // someone manually clears the file. A pointer without a pid is left alone
148
+ // (we cannot determine liveness, so keep the previous behavior).
149
+ function pidAlive(pid) {
150
+ if (pid === undefined || pid === null || pid === '') return true;
151
+ const numeric = Number(pid);
152
+ if (!Number.isInteger(numeric)) return true;
153
+ try { process.kill(numeric, 0); return true; }
154
+ catch (e) { return e.code === 'EPERM'; }
155
+ }
156
+
157
+ function runtimeIdentityEvidence(runtime) {
158
+ const snakeCase = Object.hasOwn(runtime || {}, 'process_identity');
159
+ const camelCase = Object.hasOwn(runtime || {}, 'processIdentity');
160
+ if (!snakeCase && !camelCase) return { present: false, identity: null };
161
+ if (snakeCase && camelCase) return { present: true, identity: null };
162
+ return {
163
+ present: true,
164
+ identity: snakeCase ? runtime.process_identity : runtime.processIdentity
165
+ };
166
+ }
167
+
168
+ function runtimePointerMatchesCurrentProcess(runtime, {
169
+ inspect = inspectProcessIdentity
170
+ } = {}) {
171
+ const storedEvidence = runtimeIdentityEvidence(runtime);
172
+ if (!storedEvidence.present) return pidAlive(runtime?.pid);
173
+ const stored = storedEvidence.identity;
174
+ if (!completeProcessIdentity(stored)) return false;
175
+ const pid = Number(runtime?.pid);
176
+ if (!Number.isInteger(pid) || pid <= 0 || stored.pid !== pid) return false;
177
+ return classifyRuntimePointer(runtime, { ageMs: 0, inspect }).state === 'alive';
178
+ }
179
+
180
+ function runtimeMetadataMatchesPointer(runtime, metadata) {
181
+ if (Object.hasOwn(runtime || {}, 'product') &&
182
+ (typeof runtime.product !== 'string' || !runtime.product ||
183
+ metadata?.product !== runtime.product)) return false;
184
+ const storedEvidence = runtimeIdentityEvidence(runtime);
185
+ if (!storedEvidence.present) return true;
186
+ const stored = storedEvidence.identity;
187
+ const observedEvidence = runtimeIdentityEvidence(metadata);
188
+ if (!completeProcessIdentity(stored) || !observedEvidence.present) return false;
189
+ const pointerPid = Number(runtime?.pid);
190
+ const metadataPid = Number(metadata?.pid);
191
+ const observed = observedEvidence.identity;
192
+ return Number.isInteger(pointerPid) && pointerPid > 0 && stored.pid === pointerPid &&
193
+ metadataPid === pointerPid && completeProcessIdentity(observed) &&
194
+ observed.pid === metadataPid && compareProcessIdentity(stored, observed) === 'live';
195
+ }
13
196
 
14
197
  export function readGlobalRuntimeFile() {
15
198
  const file = globalRuntimePath();
@@ -17,33 +200,34 @@ export function readGlobalRuntimeFile() {
17
200
  try {
18
201
  return JSON.parse(fs.readFileSync(file, 'utf8'));
19
202
  } catch {
20
- try { fs.rmSync(file, { force: true }); } catch {}
203
+ // Do not delete on a parse failure: a torn/partial write must not orphan a
204
+ // healthy runtime pointer. Leave it for health probing / restart to reclaim.
21
205
  return null;
22
206
  }
23
207
  }
24
208
 
25
209
  export function writeGlobalRuntime(runtime) {
26
210
  const file = globalRuntimePath();
27
- fs.mkdirSync(path.dirname(file), { recursive: true });
28
- fs.writeFileSync(file, JSON.stringify(runtime, null, 2), { mode: 0o600 });
29
- fs.chmodSync(file, 0o600);
211
+ withFileLock(file, (lockedFile) => writeJsonSafe(lockedFile, runtime, { mode: 0o600 }));
30
212
  return file;
31
213
  }
32
214
 
33
215
  export function writeRuntime(ctx, runtime) {
34
216
  const file = runtimePath(ctx);
35
- fs.mkdirSync(path.dirname(file), { recursive: true });
36
- fs.writeFileSync(file, JSON.stringify(runtime, null, 2), { mode: 0o600 });
37
- fs.chmodSync(file, 0o600);
217
+ withFileLock(file, (lockedFile) => writeJsonSafe(lockedFile, runtime, { mode: 0o600 }));
38
218
  return file;
39
219
  }
40
220
 
41
221
  export function readRuntime(ctx, opts = {}) {
42
222
  const localOnly = Boolean(opts.localOnly || process.env.HCC_RUNTIME_LOCAL_ONLY === '1');
223
+ const inspectRuntimeProcessIdentity = typeof opts.inspectProcessIdentity === 'function'
224
+ ? opts.inspectProcessIdentity
225
+ : inspectProcessIdentity;
43
226
  if (process.env.HCC_RUNTIME_URL) {
44
227
  return {
45
228
  base_url: process.env.HCC_RUNTIME_URL,
46
229
  token: process.env.HCC_RUNTIME_TOKEN || '',
230
+ tls_ca_file: process.env.HCC_RUNTIME_CA || undefined,
47
231
  source: 'env'
48
232
  };
49
233
  }
@@ -52,14 +236,20 @@ export function readRuntime(ctx, opts = {}) {
52
236
  try {
53
237
  const runtime = JSON.parse(fs.readFileSync(file, 'utf8'));
54
238
  if (!runtime.base_url) throw new Error('missing base_url');
239
+ if (!runtimePointerMatchesCurrentProcess(runtime, { inspect: inspectRuntimeProcessIdentity })) {
240
+ throw new Error('runtime process identity does not match');
241
+ }
55
242
  return { ...runtime, source: file };
56
243
  } catch {
57
- try { fs.rmSync(file, { force: true }); } catch {}
244
+ // Do not delete on a torn/partial read; fall through to the global
245
+ // runtime and let health probing / restart reclaim a truly dead file.
58
246
  }
59
247
  }
60
248
  if (!localOnly) {
61
249
  const global = readGlobalRuntimeFile();
62
- if (global?.base_url) {
250
+ if (global?.base_url && runtimePointerMatchesCurrentProcess(global, {
251
+ inspect: inspectRuntimeProcessIdentity
252
+ })) {
63
253
  return { ...global, source: globalRuntimePath(), global: true };
64
254
  }
65
255
  }
@@ -77,12 +267,14 @@ export function readRuntimeFile(ctx) {
77
267
 
78
268
  export async function probeRuntime(runtime) {
79
269
  if (!runtime?.base_url) return false;
80
- const url = runtimeApiUrl(runtime, '/api/runtime');
81
270
  const headers = {};
82
271
  if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
83
272
  try {
84
- const response = await fetch(url, { headers });
85
- return response.ok;
273
+ // xx-03: probing must not hang the CLI indefinitely against a stalled runtime.
274
+ const response = await runtimeHttpRequest(runtime, '/api/runtime', { headers, timeoutMs: 3000 });
275
+ if (!response.ok) return false;
276
+ const metadata = JSON.parse(response.text);
277
+ return metadata?.api_version === API_VERSION && runtimeMetadataMatchesPointer(runtime, metadata);
86
278
  } catch {
87
279
  return false;
88
280
  }
@@ -112,20 +304,28 @@ export async function readHealthyGlobalRuntime() {
112
304
 
113
305
  export function clearRuntime(ctx, pid = process.pid) {
114
306
  const file = runtimePath(ctx);
115
- if (fs.existsSync(file)) {
116
- try {
117
- const runtime = JSON.parse(fs.readFileSync(file, 'utf8'));
118
- if (!runtime.pid || runtime.pid === pid) fs.rmSync(file, { force: true });
119
- } catch {
120
- fs.rmSync(file, { force: true });
121
- }
122
- }
307
+ try {
308
+ withFileLock(file, (lockedFile) => {
309
+ if (!fs.existsSync(lockedFile)) return;
310
+ try {
311
+ const runtime = JSON.parse(fs.readFileSync(lockedFile, 'utf8'));
312
+ if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
313
+ } catch {
314
+ // Leave an unparseable pointer in place: only remove when we can confirm
315
+ // it belongs to this pid, so a torn write never deletes another runtime's file.
316
+ }
317
+ });
318
+ } catch {}
123
319
  const globalFile = globalRuntimePath();
124
- if (!fs.existsSync(globalFile)) return;
125
320
  try {
126
- const runtime = JSON.parse(fs.readFileSync(globalFile, 'utf8'));
127
- if (!runtime.pid || runtime.pid === pid) fs.rmSync(globalFile, { force: true });
128
- } catch {
129
- fs.rmSync(globalFile, { force: true });
130
- }
321
+ withFileLock(globalFile, (lockedFile) => {
322
+ if (!fs.existsSync(lockedFile)) return;
323
+ try {
324
+ const runtime = JSON.parse(fs.readFileSync(lockedFile, 'utf8'));
325
+ if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
326
+ } catch {
327
+ // Same as above: do not blind-delete a torn global runtime pointer.
328
+ }
329
+ });
330
+ } catch {}
131
331
  }
@@ -0,0 +1,54 @@
1
+ const CLEANUP_FAILURE = Symbol('cleanup-failure');
2
+
3
+ export function aggregateCleanupFailure(
4
+ primaryError,
5
+ cleanupError,
6
+ message,
7
+ cleanup = {}
8
+ ) {
9
+ const error = new AggregateError(
10
+ [primaryError, cleanupError],
11
+ message,
12
+ { cause: primaryError }
13
+ );
14
+ error[CLEANUP_FAILURE] = true;
15
+ error.cleanup = Object.freeze({ ...cleanup });
16
+ return error;
17
+ }
18
+
19
+ export function cleanupFailureRoot(error) {
20
+ let current = error;
21
+ let foundCleanup = false;
22
+ const seen = new Set();
23
+ while (current instanceof AggregateError && current[CLEANUP_FAILURE] === true) {
24
+ if (seen.has(current) || current.errors?.[0] !== current.cause) return null;
25
+ seen.add(current);
26
+ foundCleanup = true;
27
+ current = current.cause;
28
+ }
29
+ return foundCleanup ? current : null;
30
+ }
31
+
32
+ export function replaceCleanupFailureRoot(error, replacement) {
33
+ const seen = new Set();
34
+ function rebuild(current) {
35
+ if (!(current instanceof AggregateError) || current[CLEANUP_FAILURE] !== true) {
36
+ return replacement;
37
+ }
38
+ if (seen.has(current) || current.errors?.length !== 2 || current.errors[0] !== current.cause) {
39
+ return null;
40
+ }
41
+ seen.add(current);
42
+ const primaryError = rebuild(current.cause);
43
+ if (primaryError === null) return null;
44
+ return aggregateCleanupFailure(
45
+ primaryError,
46
+ current.errors[1],
47
+ current.message,
48
+ current.cleanup
49
+ );
50
+ }
51
+
52
+ if (!(error instanceof AggregateError) || error[CLEANUP_FAILURE] !== true) return null;
53
+ return rebuild(error);
54
+ }
@@ -0,0 +1,106 @@
1
+ // Wall-clock jump protection (hb-05). All age/TTL comparisons use wall-clock
2
+ // seconds (last_seen_at / expires_at are persisted across processes), so a
3
+ // forward jump (macOS sleep, NTP step) makes every peer look stale and every
4
+ // lock look expired at once. The long-lived web runtime detects the jump and
5
+ // persists a grace deadline in the DB meta table; every process (runtime and
6
+ // short-lived CLIs, which share the same system clock) then suppresses
7
+ // age-based decisions until the grace window passes. This is deliberately
8
+ // conservative: keeping a truly-dead peer alive for one grace window is safe;
9
+ // reaping/taking-over a live peer is not.
10
+
11
+ export const CLOCK_GRACE_SEC = 120;
12
+
13
+ function parsePersistedDeadline(value) {
14
+ if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value)) {
15
+ throw new Error('invalid persisted deadline for clock grace');
16
+ }
17
+ const persisted = Number(value);
18
+ if (!Number.isSafeInteger(persisted) || persisted < 0) {
19
+ throw new Error('invalid persisted deadline for clock grace');
20
+ }
21
+ return persisted;
22
+ }
23
+
24
+ function missingMetaTable(error) {
25
+ return /no such table:\s*(?:main\.)?meta\b/i.test(String(error?.message || ''));
26
+ }
27
+
28
+ export function readClockGraceUntil(db) {
29
+ let row;
30
+ try {
31
+ row = db.prepare("SELECT value FROM meta WHERE key = 'clock_grace_until'").get();
32
+ } catch (error) {
33
+ if (missingMetaTable(error)) return 0;
34
+ throw error;
35
+ }
36
+ if (!row) return 0;
37
+ try {
38
+ return parsePersistedDeadline(row.value);
39
+ } catch {
40
+ return 0;
41
+ }
42
+ }
43
+
44
+ export function writeClockGraceUntil(db, until) {
45
+ if (typeof until !== 'number' || !Number.isSafeInteger(until) || until < 0) {
46
+ throw new TypeError('clock grace deadline must be a non-negative safe integer');
47
+ }
48
+
49
+ let row = db.prepare(`
50
+ INSERT INTO meta(key, value) VALUES ('clock_grace_until', ?)
51
+ ON CONFLICT(key) DO UPDATE SET value = CAST(
52
+ MAX(CAST(meta.value AS INTEGER), CAST(excluded.value AS INTEGER)) AS TEXT
53
+ )
54
+ WHERE (meta.value = '0' OR (
55
+ meta.value GLOB '[1-9]*'
56
+ AND meta.value NOT GLOB '*[^0-9]*'
57
+ ))
58
+ AND CAST(meta.value AS INTEGER) BETWEEN 0 AND 9007199254740991
59
+ RETURNING value AS deadline
60
+ `).get(String(until));
61
+
62
+ if (!row) {
63
+ // CS-08: the stored value is non-canonical (corrupt or legacy). Repair it by
64
+ // overwriting with the new deadline instead of failing the whole owning
65
+ // transaction.
66
+ row = db.prepare(`
67
+ INSERT INTO meta(key, value) VALUES ('clock_grace_until', ?)
68
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
69
+ RETURNING value AS deadline
70
+ `).get(String(until));
71
+ }
72
+ if (!row) throw new Error('clock grace deadline could not be persisted');
73
+ return parsePersistedDeadline(row.deadline);
74
+ }
75
+
76
+ // True while the grace window is active at wall-clock second `nowSec`.
77
+ export function clockGraceSuppressed(nowSec, graceUntil) {
78
+ return Number(graceUntil) > Number(nowSec);
79
+ }
80
+
81
+ // Classify a wall-clock probe delta (ms between two ticks of a known-interval
82
+ // poller). Returns null when the delta is normal, or { kind } for a forward
83
+ // jump (sleep / NTP step forward) or backward jump (NTP step back).
84
+ export function classifyClockJump(deltaMs, { tickMs = 30000, forwardFactor = 3, backwardMs = 5000 } = {}) {
85
+ if (deltaMs > tickMs * forwardFactor) return { kind: 'forward', deltaMs };
86
+ if (deltaMs < -backwardMs) return { kind: 'backward', deltaMs };
87
+ return null;
88
+ }
89
+
90
+ export function classifyClockDrift({ wallDeltaMs, monotonicDeltaMs } = {}, {
91
+ forwardMs = 60000,
92
+ backwardMs = 5000
93
+ } = {}) {
94
+ if (![wallDeltaMs, monotonicDeltaMs, forwardMs, backwardMs].every(Number.isFinite) ||
95
+ monotonicDeltaMs < 0 || forwardMs < 0 || backwardMs < 0) {
96
+ throw new TypeError('clock drift inputs must be finite non-negative intervals');
97
+ }
98
+ const driftMs = wallDeltaMs - monotonicDeltaMs;
99
+ if (driftMs > forwardMs) {
100
+ return { kind: 'forward', driftMs, wallDeltaMs, monotonicDeltaMs };
101
+ }
102
+ if (driftMs < -backwardMs) {
103
+ return { kind: 'backward', driftMs, wallDeltaMs, monotonicDeltaMs };
104
+ }
105
+ return null;
106
+ }
@@ -1,3 +1,5 @@
1
+ import { cleanupFailureRoot } from './cleanup-error.mjs';
2
+
1
3
  export class CliError extends Error {
2
4
  constructor(code, message, extra = {}) {
3
5
  super(message);
@@ -5,3 +7,10 @@ export class CliError extends Error {
5
7
  this.extra = extra;
6
8
  }
7
9
  }
10
+
11
+ export function publicCliFailure(error) {
12
+ if (error instanceof CliError) return { error, cleanupFailed: false };
13
+ const rootError = cleanupFailureRoot(error);
14
+ if (rootError instanceof CliError) return { error: rootError, cleanupFailed: true };
15
+ return null;
16
+ }