@logicseek/hello-cc 0.1.9 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +50 -3
  3. package/README.zh-CN.md +48 -3
  4. package/bin/hcc.mjs +3 -5656
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/guide.md +97 -3
  12. package/docs/guide.zh-CN.md +89 -2
  13. package/lib/cli/app.mjs +644 -0
  14. package/lib/cli/commands/coordination.mjs +249 -0
  15. package/lib/cli/commands/doctor.mjs +81 -0
  16. package/lib/cli/commands/gc.mjs +1002 -0
  17. package/lib/cli/commands/hook.mjs +205 -0
  18. package/lib/cli/commands/install.mjs +294 -0
  19. package/lib/cli/commands/lifecycle.mjs +101 -0
  20. package/lib/cli/commands/lock.mjs +197 -0
  21. package/lib/cli/commands/misc.mjs +76 -0
  22. package/lib/cli/commands/msg.mjs +152 -0
  23. package/lib/cli/commands/peer.mjs +249 -0
  24. package/lib/cli/commands/query.mjs +96 -0
  25. package/lib/cli/commands/run.mjs +463 -0
  26. package/lib/cli/commands/task.mjs +405 -0
  27. package/lib/cli/commands/team.mjs +131 -0
  28. package/lib/cli/commands/tmux.mjs +838 -0
  29. package/lib/cli/commands/up.mjs +135 -0
  30. package/lib/cli-args.mjs +16 -2
  31. package/lib/coordination-state.mjs +112 -51
  32. package/lib/core/coordination/automation.mjs +5 -8
  33. package/lib/core/coordination/clock-safety.mjs +323 -0
  34. package/lib/core/coordination/event-retention.mjs +17 -0
  35. package/lib/core/coordination/gc-plan.mjs +614 -0
  36. package/lib/core/coordination/lease-renewal.mjs +102 -0
  37. package/lib/core/coordination/lock-evidence.mjs +77 -0
  38. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  39. package/lib/core/coordination/tasks.mjs +156 -36
  40. package/lib/core/peers/evidence-runtime.mjs +312 -0
  41. package/lib/core/peers/evidence.mjs +103 -0
  42. package/lib/core/peers/hook-owner.mjs +32 -0
  43. package/lib/core/peers/liveness.mjs +44 -4
  44. package/lib/core/peers/peer-helpers.mjs +121 -0
  45. package/lib/core/peers/session.mjs +8 -2
  46. package/lib/core/peers/tmux-safety.mjs +290 -0
  47. package/lib/core/sessions/launch.mjs +13 -1
  48. package/lib/db/connection.mjs +104 -0
  49. package/lib/db/events.mjs +34 -0
  50. package/lib/db/migration-backup.mjs +225 -0
  51. package/lib/db/schema.mjs +102 -5
  52. package/lib/db/stores/peers.mjs +34 -0
  53. package/lib/integrations/peers/identity.mjs +62 -16
  54. package/lib/integrations/shims/script.mjs +111 -9
  55. package/lib/process/identity.mjs +222 -0
  56. package/lib/process/pty-lifecycle.mjs +149 -0
  57. package/lib/runtime/buffer-directory-lease.mjs +48 -0
  58. package/lib/runtime/buffer-evidence.mjs +336 -0
  59. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  60. package/lib/runtime/buffer-gc.mjs +270 -0
  61. package/lib/runtime/client.mjs +63 -7
  62. package/lib/runtime/fatal-shutdown.mjs +54 -0
  63. package/lib/runtime/project-path.mjs +139 -0
  64. package/lib/runtime/projects.mjs +320 -40
  65. package/lib/runtime/state.mjs +227 -27
  66. package/lib/shared/cleanup-error.mjs +54 -0
  67. package/lib/shared/clock-grace.mjs +106 -0
  68. package/lib/shared/errors.mjs +9 -0
  69. package/lib/shared/file-lock.mjs +377 -0
  70. package/lib/shared/json-file.mjs +6 -2
  71. package/lib/shared/redact.mjs +60 -0
  72. package/lib/shared/socket-lock-worker.mjs +250 -0
  73. package/lib/terminal/tmux.mjs +131 -27
  74. package/lib/tmux.mjs +4 -0
  75. package/lib/ui/help.mjs +53 -15
  76. package/lib/web/api-version.mjs +42 -0
  77. package/lib/web/auto-attach.mjs +185 -0
  78. package/lib/web/buffer-gc-runtime.mjs +175 -0
  79. package/lib/web/cookie-auth.mjs +119 -0
  80. package/lib/web/external-sessions.mjs +254 -0
  81. package/lib/web/http-routes.mjs +542 -0
  82. package/lib/web/http.mjs +135 -6
  83. package/lib/web/liveness-reaper.mjs +145 -0
  84. package/lib/web/peer-actions.mjs +132 -23
  85. package/lib/web/project-contexts.mjs +183 -0
  86. package/lib/web/pty-sessions.mjs +203 -0
  87. package/lib/web/runtime-main.mjs +717 -0
  88. package/lib/web/runtime.mjs +91 -36
  89. package/lib/web/session-serialize.mjs +0 -0
  90. package/lib/web/startup.mjs +335 -0
  91. package/lib/web/tls.mjs +365 -0
  92. package/lib/web/tmux-sessions.mjs +1361 -0
  93. package/lib/web/tmux-stream.mjs +166 -0
  94. package/lib/web/ui-template.mjs +152 -20
  95. package/package.json +6 -3
  96. package/scripts/check-free-identifiers.mjs +290 -0
  97. package/scripts/regression.mjs +5404 -273
  98. package/docs/architecture-split-review.md +0 -217
  99. package/docs/layout-split-stack-audit.md +0 -298
@@ -0,0 +1,290 @@
1
+ import { compareProcessIdentity } from '../../process/identity.mjs';
2
+ import { tx } from '../../db/schema.mjs';
3
+ import { CliError } from '../../shared/errors.mjs';
4
+
5
+ function completeIdentity(identity) {
6
+ return Boolean(
7
+ identity &&
8
+ Number.isInteger(Number(identity.pid)) &&
9
+ Number(identity.pid) > 0 &&
10
+ identity.startToken &&
11
+ /^[a-f0-9]{64}$/i.test(String(identity.commandHash || ''))
12
+ );
13
+ }
14
+
15
+ export function validateTmuxDestructiveEvidence(stored = {}, observed = {}, options = {}) {
16
+ const authority = validateTmuxAuthorityEvidence(stored, observed);
17
+ if (!authority.ok) return authority;
18
+ if (!completeIdentity(observed.process_identity) ||
19
+ compareProcessIdentity(stored.process_identity, observed.process_identity) !== 'live') {
20
+ return { ok: false, reason: 'tmux_process_changed' };
21
+ }
22
+ return validateTmuxClients(observed, options);
23
+ }
24
+
25
+ function validateTmuxAuthorityEvidence(stored, observed) {
26
+ if (!stored.session || !stored.session_created || !stored.session_id || !stored.root || !stored.pane || !completeIdentity(stored.process_identity)) {
27
+ return { ok: false, reason: 'tmux_stored_evidence_incomplete' };
28
+ }
29
+ if (!observed.session || observed.session !== stored.session) {
30
+ return { ok: false, reason: 'tmux_session_changed' };
31
+ }
32
+ if (!observed.session_created || observed.session_created !== stored.session_created) {
33
+ return { ok: false, reason: 'tmux_session_reused' };
34
+ }
35
+ if (!observed.session_id || observed.session_id !== stored.session_id) {
36
+ return { ok: false, reason: 'tmux_session_id_changed' };
37
+ }
38
+ if (!observed.root) return { ok: false, reason: 'tmux_root_unknown' };
39
+ if (observed.root !== stored.root) return { ok: false, reason: 'tmux_root_changed' };
40
+ if (!observed.pane || observed.pane !== stored.pane) {
41
+ return { ok: false, reason: 'tmux_pane_changed' };
42
+ }
43
+ return { ok: true };
44
+ }
45
+
46
+ function validateTmuxClients(observed, options) {
47
+ if (observed.clients?.state !== 'known') {
48
+ return { ok: false, reason: 'tmux_clients_unknown' };
49
+ }
50
+ if (!options.allowClients && Number(observed.clients.count || 0) > 0) {
51
+ return { ok: false, reason: 'tmux_has_clients' };
52
+ }
53
+ return { ok: true };
54
+ }
55
+
56
+ export function validateTmuxGcDeadProcessEvidence(stored = {}, observed = {}, options = {}) {
57
+ const authority = validateTmuxAuthorityEvidence(stored, observed);
58
+ if (!authority.ok) return authority;
59
+ if (!observed.pane_dead) return { ok: false, reason: 'tmux_pane_not_dead' };
60
+ if (Number(observed.pane_pid) !== Number(stored.process_identity.pid)) {
61
+ return { ok: false, reason: 'tmux_dead_process_pid_changed' };
62
+ }
63
+ if (observed.process_inspection?.state !== 'dead' || observed.process_inspection.identity !== null) {
64
+ return { ok: false, reason: 'tmux_process_not_confirmed_dead' };
65
+ }
66
+ return validateTmuxClients(observed, options);
67
+ }
68
+
69
+ export function validateTmuxGcBindingEvidence(subject = {}, observed = {}, options = {}) {
70
+ const authority = subject.authority;
71
+ if (!subject.peer || subject.transport !== 'tmux' || !subject.runtime_target ||
72
+ !subject.expected_session || !subject.expected_root || !authority) {
73
+ return { ok: false, reason: 'tmux_binding_subject_incomplete' };
74
+ }
75
+ if (authority.session !== subject.expected_session) {
76
+ return { ok: false, reason: 'tmux_binding_session_authority_changed' };
77
+ }
78
+ if (authority.root !== subject.expected_root) {
79
+ return { ok: false, reason: 'tmux_binding_root_authority_changed' };
80
+ }
81
+ if (authority.pane !== subject.runtime_target) {
82
+ return { ok: false, reason: 'tmux_binding_pane_authority_changed' };
83
+ }
84
+ if (subject.owner_evidence?.state === 'live') {
85
+ return { ok: false, reason: 'tmux_owner_process_live' };
86
+ }
87
+
88
+ const dead = validateTmuxGcDeadProcessEvidence(authority, observed, options);
89
+ if (dead.ok) return { ok: true, mode: 'dead_process' };
90
+ if (subject.status !== 'exited') return dead;
91
+
92
+ const live = validateTmuxDestructiveEvidence(authority, observed, options);
93
+ return live.ok ? { ok: true, mode: 'explicit_exit_live' } : live;
94
+ }
95
+
96
+ export function finalizeTmuxGcBindingMutation(options = {}) {
97
+ const {
98
+ db,
99
+ plannedSubject,
100
+ readSubject,
101
+ sameSubject,
102
+ conditionalKill,
103
+ casBinding,
104
+ updatePeer,
105
+ beforeBegin
106
+ } = options;
107
+ for (const [name, fn] of Object.entries({ readSubject, sameSubject, conditionalKill, casBinding, updatePeer })) {
108
+ if (typeof fn !== 'function') throw new TypeError(`${name} must be a function`);
109
+ }
110
+ if (!db) throw new TypeError('db is required');
111
+ if (typeof beforeBegin === 'function') beforeBegin();
112
+
113
+ return tx(db, () => {
114
+ const currentSubject = readSubject();
115
+ if (!sameSubject(plannedSubject, currentSubject)) {
116
+ throw new CliError('TMUX_GC_BINDING_SUBJECT_CHANGED',
117
+ 'tmux GC binding subject changed before final mutation');
118
+ }
119
+ conditionalKill();
120
+ const bindingResult = casBinding(currentSubject);
121
+ if (Number(bindingResult?.changes) !== 1) {
122
+ throw new CliError('TMUX_GC_BINDING_CAS_FAILED',
123
+ 'tmux GC binding compare-and-swap did not update exactly one row');
124
+ }
125
+ updatePeer(currentSubject);
126
+ return currentSubject;
127
+ });
128
+ }
129
+
130
+ export function prepareTmuxRestartBinding(db, {
131
+ peer,
132
+ runtimeTarget,
133
+ nowSec
134
+ } = {}) {
135
+ if (!db || typeof db.prepare !== 'function') throw new TypeError('db is required');
136
+ if (typeof peer !== 'string' || !peer) throw new TypeError('peer is required');
137
+ if (typeof runtimeTarget !== 'string' || !runtimeTarget) {
138
+ throw new TypeError('runtimeTarget is required');
139
+ }
140
+ if (!Number.isSafeInteger(nowSec) || nowSec < 0) {
141
+ throw new TypeError('nowSec must be a non-negative safe integer');
142
+ }
143
+ const row = db.prepare(`
144
+ SELECT transport, runtime_target, updated_at
145
+ FROM peer_bindings
146
+ WHERE peer = ?
147
+ `).get(peer);
148
+ if (!row) return { ok: false, reason: 'tmux_binding_missing' };
149
+ if (row.transport !== 'tmux') return { ok: false, reason: 'tmux_binding_transport_changed' };
150
+ if (row.runtime_target === runtimeTarget) return { ok: true, restored: false };
151
+ if (row.runtime_target !== null) return { ok: false, reason: 'tmux_binding_target_changed' };
152
+
153
+ const previousUpdatedAt = Number(row.updated_at);
154
+ if (!Number.isSafeInteger(previousUpdatedAt) || previousUpdatedAt < 0) {
155
+ return { ok: false, reason: 'tmux_binding_timestamp_invalid' };
156
+ }
157
+ const preparedUpdatedAt = Math.max(nowSec, previousUpdatedAt + 1);
158
+ if (!Number.isSafeInteger(preparedUpdatedAt)) {
159
+ return { ok: false, reason: 'tmux_binding_timestamp_invalid' };
160
+ }
161
+ const updated = db.prepare(`
162
+ UPDATE peer_bindings
163
+ SET runtime_target = ?, updated_at = ?
164
+ WHERE peer = ? AND transport = 'tmux' AND runtime_target IS NULL
165
+ AND updated_at IS ?
166
+ `).run(runtimeTarget, preparedUpdatedAt, peer, previousUpdatedAt);
167
+ if (Number(updated.changes) !== 1) {
168
+ return { ok: false, reason: 'tmux_binding_subject_changed' };
169
+ }
170
+ return {
171
+ ok: true,
172
+ restored: true,
173
+ peer,
174
+ runtimeTarget,
175
+ previousUpdatedAt,
176
+ preparedUpdatedAt
177
+ };
178
+ }
179
+
180
+ export function rollbackTmuxRestartBinding(db, preparation) {
181
+ if (!preparation?.restored) return true;
182
+ const updated = db.prepare(`
183
+ UPDATE peer_bindings
184
+ SET runtime_target = NULL, updated_at = ?
185
+ WHERE peer = ? AND transport = 'tmux' AND runtime_target = ?
186
+ AND updated_at = ?
187
+ `).run(
188
+ preparation.previousUpdatedAt,
189
+ preparation.peer,
190
+ preparation.runtimeTarget,
191
+ preparation.preparedUpdatedAt
192
+ );
193
+ return Number(updated.changes) === 1;
194
+ }
195
+
196
+ const KILL_OK = 'HCC_CONDITIONAL_KILL_OK';
197
+ const KILL_MISMATCH = 'HCC_CONDITIONAL_KILL_MISMATCH';
198
+ const RENAME_OK = 'HCC_CONDITIONAL_RENAME_OK';
199
+ const RENAME_MISMATCH = 'HCC_CONDITIONAL_RENAME_MISMATCH';
200
+
201
+ function conditionalFields(stored, {
202
+ code = 'TMUX_CONDITIONAL_KILL_MISMATCH',
203
+ operation = 'conditional-kill'
204
+ } = {}) {
205
+ const fields = {
206
+ session: String(stored?.session || ''),
207
+ sessionCreated: String(stored?.session_created || ''),
208
+ sessionId: String(stored?.session_id || ''),
209
+ pane: String(stored?.pane || ''),
210
+ panePid: String(stored?.process_identity?.pid || '')
211
+ };
212
+ if (!/^[a-zA-Z0-9_.-]+$/.test(fields.session) ||
213
+ !/^\d+$/.test(fields.sessionCreated) ||
214
+ !/^\$\d+$/.test(fields.sessionId) ||
215
+ !/^%\d+$/.test(fields.pane) ||
216
+ !/^\d+$/.test(fields.panePid)) {
217
+ throw new CliError(code, `Stored tmux ${operation} identity is incomplete or invalid`);
218
+ }
219
+ return fields;
220
+ }
221
+
222
+ function tmuxIdentityCondition(fields) {
223
+ return `#{&&:#{==:#{session_name},${fields.session}},#{&&:#{==:#{session_created},${fields.sessionCreated}},#{&&:#{==:#{session_id},${fields.sessionId}},#{&&:#{==:#{pane_id},${fields.pane}},#{==:#{pane_pid},${fields.panePid}}}}}}`;
224
+ }
225
+
226
+ export function conditionalTmuxKill(runTmux, stored, options = {}) {
227
+ if (typeof runTmux !== 'function') throw new TypeError('runTmux must be a function');
228
+ const fields = conditionalFields(stored);
229
+ if (typeof options.beforeConditional === 'function') options.beforeConditional();
230
+ const identityCondition = tmuxIdentityCondition(fields);
231
+ const processCondition = options.requireDeadPane
232
+ ? `#{&&:${identityCondition},#{==:#{pane_dead},1}}`
233
+ : identityCondition;
234
+ const condition = options.allowClients
235
+ ? processCondition
236
+ : `#{&&:${processCondition},#{==:#{session_attached},0}}`;
237
+ const successCommand = `display-message -p ${KILL_OK} ; kill-session -t '${fields.sessionId}'`;
238
+ let output = '';
239
+ try {
240
+ output = runTmux([
241
+ 'if-shell', '-F', '-t', fields.session,
242
+ condition,
243
+ successCommand,
244
+ `display-message -p ${KILL_MISMATCH}`
245
+ ]);
246
+ } catch (error) {
247
+ if (error instanceof CliError) throw error;
248
+ throw new CliError('TMUX_ERROR',
249
+ `Conditional tmux kill command failed: ${error?.message || error}`);
250
+ }
251
+ if (!String(output).split('\n').includes(KILL_OK)) {
252
+ throw new CliError('TMUX_CONDITIONAL_KILL_MISMATCH', 'Conditional tmux kill target changed before execution');
253
+ }
254
+ return { killed: true };
255
+ }
256
+
257
+ export function conditionalTmuxRename(runTmux, stored, newName, options = {}) {
258
+ if (typeof runTmux !== 'function') throw new TypeError('runTmux must be a function');
259
+ const fields = conditionalFields(stored, {
260
+ code: 'TMUX_CONDITIONAL_RENAME_MISMATCH',
261
+ operation: 'conditional-rename'
262
+ });
263
+ const targetName = String(newName || '');
264
+ if (!/^[a-zA-Z0-9_.-]+$/.test(targetName) || targetName === fields.session) {
265
+ throw new CliError('TMUX_CONDITIONAL_RENAME_MISMATCH', 'Replacement tmux session name is incomplete or invalid');
266
+ }
267
+ if (typeof options.beforeConditional === 'function') options.beforeConditional();
268
+ const identityCondition = tmuxIdentityCondition(fields);
269
+ const condition = options.allowClients
270
+ ? identityCondition
271
+ : `#{&&:${identityCondition},#{==:#{session_attached},0}}`;
272
+ const successCommand = `display-message -p ${RENAME_OK} ; rename-session -t '${fields.sessionId}' ${targetName}`;
273
+ let output = '';
274
+ try {
275
+ output = runTmux([
276
+ 'if-shell', '-F', '-t', fields.session,
277
+ condition,
278
+ successCommand,
279
+ `display-message -p ${RENAME_MISMATCH}`
280
+ ]);
281
+ } catch (error) {
282
+ if (error instanceof CliError) throw error;
283
+ throw new CliError('TMUX_ERROR',
284
+ `Conditional tmux rename command failed: ${error?.message || error}`);
285
+ }
286
+ if (!String(output).split('\n').includes(RENAME_OK)) {
287
+ throw new CliError('TMUX_CONDITIONAL_RENAME_MISMATCH', 'Conditional tmux rename target changed before execution');
288
+ }
289
+ return { renamed: true, session: targetName };
290
+ }
@@ -31,9 +31,14 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
31
31
  'SHLVL',
32
32
  'TERM',
33
33
  'TERMCAP',
34
+ 'TERM_SESSION_ID',
35
+ 'TMPDIR',
34
36
  'TMUX',
35
37
  'TMUX_PANE',
36
- 'WINDOWID'
38
+ 'WINDOWID',
39
+ 'XPC_FLAGS',
40
+ 'XPC_SERVICE_NAME',
41
+ 'SSH_AUTH_SOCK'
37
42
  ]);
38
43
 
39
44
  export function childSessionEnv(extra = {}, baseEnv = process.env) {
@@ -79,6 +84,13 @@ export function isRelaunchableProviderSession(kind, command, binding = {}) {
79
84
  return ['claude', 'codex'].includes(provider) && isProviderFallbackWrapper(command);
80
85
  }
81
86
 
87
+ export function providerRestartReason({ providerState, ownerEvidence, paneCommand } = {}) {
88
+ if (ownerEvidence?.state !== 'dead') return null;
89
+ if (providerState === 'exited') return 'provider_exited';
90
+ if (!providerState && isLikelyShellCommand(paneCommand)) return 'provider_fallback_shell';
91
+ return null;
92
+ }
93
+
82
94
  export function isolatedEnvCommandArgs(env) {
83
95
  const envBin = fs.existsSync('/usr/bin/env') ? '/usr/bin/env' : 'env';
84
96
  const args = [envBin, '-i'];
@@ -0,0 +1,104 @@
1
+ // Database connection helpers extracted from bin/hcc.mjs.
2
+ // Factory pattern: callers inject the functions that remain in bin/hcc.mjs
3
+ // (now, dedupePeerBindings, redactedLogText) while everything else is imported
4
+ // directly from lib/ modules.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+ import { CliError } from '../shared/errors.mjs';
10
+ import {
11
+ DB_SCHEMA_VERSION,
12
+ execWithBusyRetry
13
+ } from './schema.mjs';
14
+ import { initSchemaWithBackup } from './migration-backup.mjs';
15
+ import { readProjectRegistry } from '../runtime/projects.mjs';
16
+ import { resolveProjectDatabase } from '../runtime/project-path.mjs';
17
+
18
+ const MIGRATION_FAILURE_COOLDOWN_MS = 5 * 60 * 1000;
19
+
20
+ export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogText }) {
21
+ let projectMigrationFanoutDepth = 0;
22
+ const migratedRegisteredProjectDbs = new Set();
23
+
24
+ function connect(ctx, options = {}) {
25
+ if (options.create === false && !fs.existsSync(ctx.dbPath)) {
26
+ throw new CliError('NOT_FOUND', `Project database does not exist: ${ctx.dbPath}`);
27
+ }
28
+ if (options.create !== false) fs.mkdirSync(path.dirname(ctx.dbPath), { recursive: true });
29
+ const db = new DatabaseSync(ctx.dbPath, { timeout: 5000 });
30
+ db.exec('PRAGMA busy_timeout = 5000;');
31
+ execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
32
+ db.exec('PRAGMA synchronous = NORMAL;');
33
+ db.exec('PRAGMA wal_autocheckpoint = 1000;');
34
+ db.exec('PRAGMA foreign_keys = ON;');
35
+ initSchemaWithBackup(db, ctx.dbPath, {
36
+ beforePostMigrationIndexes: dedupePeerBindings
37
+ });
38
+ if (options.migrateRegistered !== false) migrateRegisteredProjectDbs(ctx);
39
+ return db;
40
+ }
41
+
42
+ function connectReadOnly(ctx) {
43
+ if (!fs.existsSync(ctx.dbPath) || !fs.statSync(ctx.dbPath).isFile()) {
44
+ throw new CliError('NOT_FOUND', `Project database does not exist: ${ctx.dbPath}`);
45
+ }
46
+ return new DatabaseSync(ctx.dbPath, { timeout: 5000, readOnly: true });
47
+ }
48
+
49
+ function migrateRegisteredProjectDbs(ctx) {
50
+ if (projectMigrationFanoutDepth > 0) return;
51
+ projectMigrationFanoutDepth += 1;
52
+ try {
53
+ const currentDb = path.resolve(ctx.dbPath);
54
+ const seen = new Set([currentDb]);
55
+ for (const project of readProjectRegistry()) {
56
+ let resolved;
57
+ try {
58
+ resolved = resolveProjectDatabase({
59
+ root: project.root,
60
+ db: project.db || path.join(project.root, '.hello-cc', 'mesh.db'),
61
+ createStateDir: false
62
+ });
63
+ } catch (err) {
64
+ console.error(redactedLogText(`[${new Date().toISOString()}] skipping registered project DB migration for ${project.db || project.root}: ${err?.message || err}`));
65
+ continue;
66
+ }
67
+ const root = resolved.root;
68
+ const dbPath = resolved.db;
69
+ if (seen.has(dbPath)) continue;
70
+ seen.add(dbPath);
71
+ const cacheKey = `${dbPath}:${DB_SCHEMA_VERSION}`;
72
+ if (migratedRegisteredProjectDbs.has(cacheKey)) continue;
73
+ if (!fs.existsSync(root) || !fs.existsSync(dbPath)) continue;
74
+ const failedMarker = `${dbPath}.migration-failed`;
75
+ try {
76
+ if (Date.now() - fs.statSync(failedMarker).mtimeMs < MIGRATION_FAILURE_COOLDOWN_MS) continue;
77
+ } catch {}
78
+ let db = null;
79
+ try {
80
+ db = new DatabaseSync(dbPath, { timeout: 5000 });
81
+ db.exec('PRAGMA busy_timeout = 5000;');
82
+ execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
83
+ db.exec('PRAGMA synchronous = NORMAL;');
84
+ db.exec('PRAGMA foreign_keys = ON;');
85
+ initSchemaWithBackup(db, dbPath, {
86
+ beforePostMigrationIndexes: dedupePeerBindings
87
+ });
88
+ migratedRegisteredProjectDbs.add(cacheKey);
89
+ try { fs.rmSync(failedMarker, { force: true }); } catch {}
90
+ } catch (err) {
91
+ console.error(redactedLogText(`[${new Date().toISOString()}] skipping registered project DB migration for ${dbPath}: ${err?.message || err}`));
92
+ try { fs.writeFileSync(failedMarker, String(now())); } catch {}
93
+ continue;
94
+ } finally {
95
+ try { db?.close(); } catch {}
96
+ }
97
+ }
98
+ } finally {
99
+ projectMigrationFanoutDepth -= 1;
100
+ }
101
+ }
102
+
103
+ return { connect, connectReadOnly, migrateRegisteredProjectDbs };
104
+ }
@@ -0,0 +1,34 @@
1
+ // Shared DB event/audit helpers extracted from bin/hcc.mjs.
2
+ // These are pure functions with a single dependency (now()) that callers
3
+ // inject, so they can be reused by CLI commands, web routes, and tests.
4
+
5
+ function defaultNow() {
6
+ return Math.floor(Date.now() / 1000);
7
+ }
8
+
9
+ export function createEventHelpers({ now = defaultNow } = {}) {
10
+ function addEvent(db, type, actor, taskId, payload) {
11
+ db.prepare(`
12
+ INSERT INTO events(type, actor, task_id, payload, created_at)
13
+ VALUES (?, ?, ?, ?, ?)
14
+ `).run(type, actor || null, taskId || null, JSON.stringify(payload || {}), now());
15
+ }
16
+
17
+ function auditPayload({ actor = null, target = null, source = 'cli', admin = false, ...extra } = {}) {
18
+ const payload = { ...extra, source };
19
+ if (actor) payload.actor_peer = actor;
20
+ if (target) payload.target_peer = target;
21
+ if (admin) payload.admin = true;
22
+ return payload;
23
+ }
24
+
25
+ function requestActorPeer(input = {}, fallback = 'web') {
26
+ return String(input.auditActorPeer || fallback || 'web').trim() || 'web';
27
+ }
28
+
29
+ function requestSource(input = {}, fallback = 'web') {
30
+ return String(input.auditSource || fallback || 'web').trim() || fallback;
31
+ }
32
+
33
+ return { addEvent, auditPayload, requestActorPeer, requestSource };
34
+ }
@@ -0,0 +1,225 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import {
6
+ DB_SCHEMA_VERSION,
7
+ initSchema,
8
+ readSchemaVersion
9
+ } from './schema.mjs';
10
+ import { withFileLock } from '../shared/file-lock.mjs';
11
+
12
+ const MAX_BACKUP_COMPONENT_BYTES = 240;
13
+ // v1-02: keep at most this many migration backups per database basename.
14
+ const MAX_BACKUPS_PER_DB = 5;
15
+
16
+ function quoteSqlString(value) {
17
+ return `'${String(value).replaceAll("'", "''")}'`;
18
+ }
19
+
20
+ function defaultTimestamp() {
21
+ return new Date().toISOString().replace(/[-:.]/g, '');
22
+ }
23
+
24
+ function defaultSuffix() {
25
+ return randomBytes(4).toString('hex');
26
+ }
27
+
28
+ function backupFilename(dbBasename, fromVersion, toVersion, timestamp, suffix) {
29
+ return `${dbBasename}.pre-v${fromVersion}-to-v${toVersion}.${timestamp}.${suffix}.bak`;
30
+ }
31
+
32
+ function boundedDbBasename(dbPath, fromVersion, toVersion, timestamp) {
33
+ const dbBasename = path.basename(dbPath);
34
+ const finalName = backupFilename(dbBasename, fromVersion, toVersion, timestamp, 'ffffffff');
35
+ const stagingName = `.${dbBasename}.migration-backup-XXXXXX`;
36
+ if (Buffer.byteLength(finalName) <= MAX_BACKUP_COMPONENT_BYTES &&
37
+ Buffer.byteLength(stagingName) <= MAX_BACKUP_COMPONENT_BYTES) {
38
+ return dbBasename;
39
+ }
40
+ const hash = createHash('sha256').update(dbBasename).digest('hex').slice(0, 16);
41
+ return `db-${hash}`;
42
+ }
43
+
44
+ function syncPath(target) {
45
+ const fd = fs.openSync(target, 'r');
46
+ let syncError = null;
47
+ try {
48
+ fs.fsyncSync(fd);
49
+ } catch (err) {
50
+ syncError = err;
51
+ }
52
+ try {
53
+ fs.closeSync(fd);
54
+ } catch (closeError) {
55
+ if (syncError) {
56
+ throw new AggregateError([syncError, closeError], `Failed to sync and close ${target}`);
57
+ }
58
+ throw closeError;
59
+ }
60
+ if (syncError) throw syncError;
61
+ }
62
+
63
+ function removeStagingDirectory(tempDirectory) {
64
+ let lastError;
65
+ for (let attempt = 0; attempt < 2; attempt += 1) {
66
+ try {
67
+ fs.rmSync(tempDirectory, { recursive: true, force: true });
68
+ return;
69
+ } catch (err) {
70
+ lastError = err;
71
+ }
72
+ }
73
+ throw new Error(`Failed to remove migration backup staging directory: ${tempDirectory}`, {
74
+ cause: lastError
75
+ });
76
+ }
77
+
78
+ function verifyBackup(backupPath) {
79
+ const backup = new DatabaseSync(backupPath, { readOnly: true });
80
+ try {
81
+ const rows = backup.prepare('PRAGMA quick_check').all();
82
+ if (rows.length !== 1 || rows[0]?.quick_check !== 'ok') {
83
+ throw new Error(`Migration backup failed SQLite quick_check: ${backupPath}`);
84
+ }
85
+ } finally {
86
+ backup.close();
87
+ }
88
+ }
89
+
90
+ export function initSchemaWithBackup(db, dbPath, {
91
+ beforePostMigrationIndexes = null,
92
+ ensureBackup = ensureMigrationBackup,
93
+ withLock = withFileLock
94
+ } = {}) {
95
+ const initialize = (lockedDbPath = dbPath) => initSchema(db, {
96
+ beforeMigration: ({ fromVersion, toVersion }) =>
97
+ ensureBackup(db, lockedDbPath, fromVersion, toVersion),
98
+ beforePostMigrationIndexes
99
+ });
100
+ if (readSchemaVersion(db) >= DB_SCHEMA_VERSION) return initialize();
101
+ return withLock(dbPath, (lockedDbPath) => initialize(lockedDbPath));
102
+ }
103
+
104
+ export function ensureMigrationBackup(db, dbPath, fromVersion, toVersion, {
105
+ timestamp = defaultTimestamp,
106
+ suffix = defaultSuffix,
107
+ verifySnapshot = verifyBackup
108
+ } = {}) {
109
+ if (!Number.isInteger(fromVersion) || fromVersion < 0 ||
110
+ !Number.isInteger(toVersion) || toVersion <= fromVersion) {
111
+ throw new TypeError(`Invalid migration version range: ${fromVersion} to ${toVersion}`);
112
+ }
113
+ // v1-03: under a racing migration, this connection may already observe the
114
+ // migrated version (another process migrated between our stale version read
115
+ // and this call). Backing it up would mislabel an already-migrated DB as
116
+ // pre-v<from>-to-v<to>; skip the backup entirely in that case.
117
+ try {
118
+ const pragma = db.prepare('PRAGMA user_version').get();
119
+ const actualVersion = Number(pragma?.user_version || 0);
120
+ if (Number.isInteger(actualVersion) && actualVersion >= toVersion) return null;
121
+ } catch {
122
+ // Cannot read the version — proceed conservatively with the backup.
123
+ }
124
+ const timestampValue = timestamp();
125
+ if (!/^\d{8}T\d{9}Z$/.test(timestampValue)) {
126
+ throw new TypeError(`Invalid migration backup timestamp: ${timestampValue}`);
127
+ }
128
+
129
+ const directory = path.dirname(path.resolve(dbPath));
130
+ const dbBasename = boundedDbBasename(dbPath, fromVersion, toVersion, timestampValue);
131
+ let tempDirectory;
132
+ let backupPath;
133
+ let operationError = null;
134
+ try {
135
+ tempDirectory = fs.mkdtempSync(path.join(directory, `.${dbBasename}.migration-backup-`));
136
+ fs.chmodSync(tempDirectory, 0o700);
137
+ const tempPath = path.join(tempDirectory, 'snapshot.db');
138
+ db.exec(`VACUUM INTO ${quoteSqlString(tempPath)};`);
139
+ fs.chmodSync(tempPath, 0o600);
140
+ syncPath(tempPath);
141
+ verifySnapshot(tempPath);
142
+
143
+ for (let attempt = 0; attempt < 128; attempt += 1) {
144
+ const suffixValue = suffix();
145
+ if (!/^[a-f0-9]{8}$/.test(suffixValue)) {
146
+ throw new TypeError(`Invalid migration backup suffix: ${suffixValue}`);
147
+ }
148
+ const candidate = path.join(
149
+ directory,
150
+ backupFilename(dbBasename, fromVersion, toVersion, timestampValue, suffixValue)
151
+ );
152
+ try {
153
+ // Linking publishes the already-verified inode exclusively. EEXIST is
154
+ // safe for regular files and symlinks: neither is followed or replaced.
155
+ fs.linkSync(tempPath, candidate);
156
+ backupPath = candidate;
157
+ break;
158
+ } catch (err) {
159
+ if (err?.code === 'EEXIST') continue;
160
+ throw err;
161
+ }
162
+ }
163
+ if (!backupPath) throw new Error('Unable to allocate a unique migration backup path');
164
+ } catch (err) {
165
+ operationError = err;
166
+ }
167
+
168
+ let cleanupError = null;
169
+ if (tempDirectory) {
170
+ try {
171
+ removeStagingDirectory(tempDirectory);
172
+ } catch (err) {
173
+ cleanupError = err;
174
+ }
175
+ }
176
+ if (operationError && cleanupError) {
177
+ throw new AggregateError(
178
+ [operationError, cleanupError],
179
+ 'Migration backup failed and staging cleanup also failed'
180
+ );
181
+ }
182
+ if (operationError) throw operationError;
183
+ if (cleanupError) throw cleanupError;
184
+
185
+ syncPath(directory);
186
+ rotateBackups(directory, dbBasename, backupPath);
187
+ return backupPath;
188
+ }
189
+
190
+ // v1-02: prune old migration backups for this database, keeping the most
191
+ // recent MAX_BACKUPS_PER_DB. Backups are full DB copies; without rotation a
192
+ // long-lived project accumulates them on every failed/racing migration attempt.
193
+ function rotateBackups(directory, dbBasename, publishedPath) {
194
+ const escapedBasename = dbBasename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
195
+ const backupPattern = new RegExp(
196
+ `^${escapedBasename}\\.pre-v(\\d+)-to-v(\\d+)\\.(\\d{8}T\\d{9}Z)\\.([a-f0-9]{8})\\.bak$`
197
+ );
198
+ const publishedName = path.basename(publishedPath);
199
+ let entries = [];
200
+ try {
201
+ entries = fs.readdirSync(directory);
202
+ } catch {
203
+ return;
204
+ }
205
+ const backups = entries
206
+ .map((name) => {
207
+ const match = name.match(backupPattern);
208
+ if (!match) return null;
209
+ try {
210
+ if (!fs.lstatSync(path.join(directory, name)).isFile()) return null;
211
+ return { name, timestamp: match[3] };
212
+ } catch {
213
+ return null;
214
+ }
215
+ })
216
+ .filter(Boolean)
217
+ .sort((a, b) => {
218
+ if (a.name === publishedName) return b.name === publishedName ? 0 : -1;
219
+ if (b.name === publishedName) return 1;
220
+ return b.timestamp.localeCompare(a.timestamp) || b.name.localeCompare(a.name);
221
+ });
222
+ for (const old of backups.slice(MAX_BACKUPS_PER_DB)) {
223
+ try { fs.rmSync(path.join(directory, old.name), { force: true }); } catch {}
224
+ }
225
+ }