agentic-kdd 3.5.6 → 3.5.8

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.
@@ -0,0 +1,545 @@
1
+ 'use strict';
2
+ /**
3
+ * Agentic KDD — Lock Manager v2.0
4
+ * Lock real para desarrollo multi-instancia en el mismo proyecto.
5
+ *
6
+ * v2.0 fixes:
7
+ * - WAL mode en SQLite para escrituras concurrentes reales
8
+ * - Acquire atómico con transacción (elimina TOCTOU race condition)
9
+ * - Paths normalizados a relativos lowercase para evitar false misses
10
+ * - INSTANCE_ID persistido en disco (.agentic/_instance_id) — sobrevive restarts
11
+ * - Deadlock detection antes de cada acquire
12
+ * - Cleanup automático de locks huérfanos al iniciar
13
+ *
14
+ * Uso:
15
+ * node .agentic/grafo/lock-manager.cjs acquire --module=auth --files=src/auth.ts
16
+ * node .agentic/grafo/lock-manager.cjs release --module=auth
17
+ * node .agentic/grafo/lock-manager.cjs status
18
+ * node .agentic/grafo/lock-manager.cjs check --files=src/auth.ts,src/middleware.ts
19
+ * node .agentic/grafo/lock-manager.cjs acquire-schema
20
+ * node .agentic/grafo/lock-manager.cjs release-schema
21
+ * node .agentic/grafo/lock-manager.cjs release-all
22
+ * node .agentic/grafo/lock-manager.cjs wait --module=auth [--timeout=300]
23
+ */
24
+
25
+ const path = require('path');
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const crypto = require('crypto');
29
+
30
+ const ROOT = process.cwd();
31
+ const DB_PATH = path.join(ROOT, '.agentic', 'memoria.db');
32
+ const INST_FILE = path.join(ROOT, '.agentic', '_instance_id');
33
+
34
+ const LOCK_TTL_MINUTES = 30;
35
+ const SCHEMA_TTL_MINUTES = 10;
36
+ const WAIT_POLL_MS = 2000;
37
+
38
+ // ── INSTANCE_ID persistido ────────────────────────────────────────────────────
39
+ // Sobrevive reinicios de Cursor — permite liberar locks de sesiones anteriores
40
+
41
+ function getOrCreateInstanceId() {
42
+ try {
43
+ if (fs.existsSync(INST_FILE)) {
44
+ const id = fs.readFileSync(INST_FILE, 'utf8').trim();
45
+ if (id && id.startsWith('inst_')) return id;
46
+ }
47
+ } catch {}
48
+ const id = `inst_${os.hostname().replace(/[^a-zA-Z0-9]/g,'')}_${crypto.randomBytes(6).toString('hex')}`;
49
+ try { fs.writeFileSync(INST_FILE, id, 'utf8'); } catch {}
50
+ return id;
51
+ }
52
+
53
+ const INSTANCE_ID = process.env.AGENTIC_INSTANCE_ID || getOrCreateInstanceId();
54
+
55
+ // ── DB setup ─────────────────────────────────────────────────────────────────
56
+
57
+ function openDB() {
58
+ const projNodeModules = path.join(ROOT, 'node_modules');
59
+ if (!module.paths.includes(projNodeModules)) module.paths.unshift(projNodeModules);
60
+ const db = new (require('better-sqlite3'))(DB_PATH);
61
+ // WAL mode: permite lecturas concurrentes mientras se escribe
62
+ db.pragma('journal_mode = WAL');
63
+ db.pragma('busy_timeout = 5000'); // esperar hasta 5s si la BD está ocupada
64
+ return db;
65
+ }
66
+
67
+ function ensureSchema(db) {
68
+ db.exec(`
69
+ CREATE TABLE IF NOT EXISTS module_locks (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ module_name TEXT NOT NULL UNIQUE,
72
+ instance_id TEXT NOT NULL,
73
+ files TEXT NOT NULL DEFAULT '[]',
74
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
75
+ expires_at TEXT NOT NULL,
76
+ purpose TEXT,
77
+ pid INTEGER
78
+ )
79
+ `);
80
+ db.exec(`
81
+ CREATE TABLE IF NOT EXISTS file_locks (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ file_path TEXT NOT NULL UNIQUE,
84
+ module_name TEXT NOT NULL,
85
+ instance_id TEXT NOT NULL,
86
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
87
+ expires_at TEXT NOT NULL
88
+ )
89
+ `);
90
+ db.exec(`
91
+ CREATE TABLE IF NOT EXISTS schema_lock (
92
+ id INTEGER PRIMARY KEY CHECK (id = 1),
93
+ instance_id TEXT NOT NULL,
94
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
95
+ expires_at TEXT NOT NULL,
96
+ purpose TEXT,
97
+ pid INTEGER
98
+ )
99
+ `);
100
+ db.exec(`
101
+ CREATE TABLE IF NOT EXISTS lock_waiters (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ module_name TEXT NOT NULL,
104
+ instance_id TEXT NOT NULL,
105
+ waiting_since TEXT NOT NULL DEFAULT (datetime('now')),
106
+ timeout_at TEXT NOT NULL
107
+ )
108
+ `);
109
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_ml_inst ON module_locks(instance_id)"); } catch {}
110
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_path ON file_locks(file_path)"); } catch {}
111
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_module ON file_locks(module_name)"); } catch {}
112
+ }
113
+
114
+ // ── Helpers ───────────────────────────────────────────────────────────────────
115
+
116
+ function expiresAt(minutes) {
117
+ return new Date(Date.now() + minutes * 60 * 1000).toISOString();
118
+ }
119
+
120
+ function waitTimeoutAt(seconds) {
121
+ return new Date(Date.now() + seconds * 1000).toISOString();
122
+ }
123
+
124
+ // Normaliza paths: absoluto → relativo, backslashes → forward, lowercase en Windows
125
+ function normalizePath(filePath) {
126
+ let p = filePath;
127
+ // Convertir absoluto a relativo si está bajo ROOT
128
+ if (path.isAbsolute(p)) {
129
+ const rel = path.relative(ROOT, p);
130
+ if (!rel.startsWith('..')) p = rel;
131
+ }
132
+ // Forward slashes siempre
133
+ p = p.replace(/\\/g, '/');
134
+ // Quitar ./ inicial
135
+ if (p.startsWith('./')) p = p.slice(2);
136
+ return p;
137
+ }
138
+
139
+ function normalizePaths(files) {
140
+ return [...new Set(files.map(normalizePath))];
141
+ }
142
+
143
+ function purgeExpired(db) {
144
+ db.prepare("DELETE FROM module_locks WHERE expires_at < datetime('now')").run();
145
+ db.prepare("DELETE FROM file_locks WHERE expires_at < datetime('now')").run();
146
+ db.prepare("DELETE FROM schema_lock WHERE expires_at < datetime('now')").run();
147
+ db.prepare("DELETE FROM lock_waiters WHERE timeout_at < datetime('now')").run();
148
+ }
149
+
150
+ // ── Deadlock detection ────────────────────────────────────────────────────────
151
+ // Detecta ciclos: A espera B, B espera A → deadlock
152
+
153
+ function detectDeadlock(db, requestingInstance, targetModule) {
154
+ // ¿Quién tiene targetModule?
155
+ const holder = db.prepare(
156
+ "SELECT instance_id FROM module_locks WHERE module_name = ?"
157
+ ).get(targetModule);
158
+ if (!holder) return { deadlock: false };
159
+
160
+ const holderInstance = holder.instance_id;
161
+ if (holderInstance === requestingInstance) return { deadlock: false };
162
+
163
+ // ¿El holder está esperando algún módulo que tenga requestingInstance?
164
+ const holderWaiting = db.prepare(
165
+ "SELECT module_name FROM lock_waiters WHERE instance_id = ?"
166
+ ).all(holderInstance);
167
+
168
+ for (const w of holderWaiting) {
169
+ const wastedModuleHolder = db.prepare(
170
+ "SELECT instance_id FROM module_locks WHERE module_name = ?"
171
+ ).get(w.module_name);
172
+ if (wastedModuleHolder && wastedModuleHolder.instance_id === requestingInstance) {
173
+ return {
174
+ deadlock: true,
175
+ cycle: `${requestingInstance} → [${targetModule}] → ${holderInstance} → [${w.module_name}] → ${requestingInstance}`,
176
+ };
177
+ }
178
+ }
179
+
180
+ return { deadlock: false };
181
+ }
182
+
183
+ // ── Acquire module lock (atómico) ─────────────────────────────────────────────
184
+
185
+ function acquireModuleLock(db, moduleName, files = [], purpose = '') {
186
+ const normalFiles = normalizePaths(files);
187
+
188
+ // Transacción atómica — elimina TOCTOU
189
+ const acquire = db.transaction(() => {
190
+ purgeExpired(db);
191
+
192
+ // 1. Verificar si el módulo está bloqueado por otra instancia
193
+ const existingModule = db.prepare(
194
+ "SELECT * FROM module_locks WHERE module_name = ?"
195
+ ).get(moduleName);
196
+
197
+ if (existingModule && existingModule.instance_id !== INSTANCE_ID) {
198
+ // Detectar deadlock antes de reportar bloqueo
199
+ const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
200
+ return {
201
+ success: false,
202
+ reason: `Module "${moduleName}" locked by ${existingModule.instance_id}`,
203
+ locked_by: existingModule.instance_id,
204
+ expires_at: existingModule.expires_at,
205
+ deadlock: dl.deadlock,
206
+ deadlock_cycle: dl.cycle,
207
+ };
208
+ }
209
+
210
+ // 2. Verificar conflictos de archivos con otras instancias
211
+ const fileConflicts = [];
212
+ for (const file of normalFiles) {
213
+ const fileLock = db.prepare(
214
+ "SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
215
+ ).get(file, INSTANCE_ID);
216
+ if (fileLock) {
217
+ fileConflicts.push({ file, locked_by_module: fileLock.module_name, locked_by_instance: fileLock.instance_id });
218
+ }
219
+ }
220
+
221
+ if (fileConflicts.length > 0) {
222
+ return { success: false, reason: 'File conflicts', conflicts: fileConflicts };
223
+ }
224
+
225
+ // 3. Adquirir lock de módulo
226
+ const exp = expiresAt(LOCK_TTL_MINUTES);
227
+ db.prepare(`
228
+ INSERT INTO module_locks (module_name, instance_id, files, acquired_at, expires_at, purpose, pid)
229
+ VALUES (?, ?, ?, datetime('now'), ?, ?, ?)
230
+ ON CONFLICT(module_name) DO UPDATE SET
231
+ instance_id=excluded.instance_id, files=excluded.files,
232
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
233
+ purpose=excluded.purpose, pid=excluded.pid
234
+ `).run(moduleName, INSTANCE_ID, JSON.stringify(normalFiles), exp, purpose, process.pid);
235
+
236
+ // 4. Adquirir locks de archivos
237
+ for (const file of normalFiles) {
238
+ db.prepare(`
239
+ INSERT INTO file_locks (file_path, module_name, instance_id, acquired_at, expires_at)
240
+ VALUES (?, ?, ?, datetime('now'), ?)
241
+ ON CONFLICT(file_path) DO UPDATE SET
242
+ module_name=excluded.module_name, instance_id=excluded.instance_id,
243
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at
244
+ `).run(file, moduleName, INSTANCE_ID, exp);
245
+ }
246
+
247
+ // 5. Remover de waiters si estaba esperando
248
+ db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?")
249
+ .run(moduleName, INSTANCE_ID);
250
+
251
+ return { success: true, instance_id: INSTANCE_ID, module: moduleName, files: normalFiles, expires_at: exp };
252
+ });
253
+
254
+ return acquire();
255
+ }
256
+
257
+ // ── Release module lock ───────────────────────────────────────────────────────
258
+
259
+ function releaseModuleLock(db, moduleName) {
260
+ const release = db.transaction(() => {
261
+ const lock = db.prepare(
262
+ "SELECT * FROM module_locks WHERE module_name = ? AND instance_id = ?"
263
+ ).get(moduleName, INSTANCE_ID);
264
+ if (!lock) return { success: false, reason: `No lock owned by this instance for [${moduleName}]` };
265
+
266
+ db.prepare("DELETE FROM module_locks WHERE module_name = ? AND instance_id = ?")
267
+ .run(moduleName, INSTANCE_ID);
268
+ db.prepare("DELETE FROM file_locks WHERE module_name = ? AND instance_id = ?")
269
+ .run(moduleName, INSTANCE_ID);
270
+
271
+ return { success: true, module: moduleName };
272
+ });
273
+ return release();
274
+ }
275
+
276
+ // ── Release all locks for this instance ──────────────────────────────────────
277
+
278
+ function releaseAll(db) {
279
+ const rel = db.transaction(() => {
280
+ const modules = db.prepare("SELECT module_name FROM module_locks WHERE instance_id = ?")
281
+ .all(INSTANCE_ID).map(r => r.module_name);
282
+ db.prepare("DELETE FROM module_locks WHERE instance_id = ?").run(INSTANCE_ID);
283
+ db.prepare("DELETE FROM file_locks WHERE instance_id = ?").run(INSTANCE_ID);
284
+ db.prepare("DELETE FROM schema_lock WHERE instance_id = ?").run(INSTANCE_ID);
285
+ db.prepare("DELETE FROM lock_waiters WHERE instance_id = ?").run(INSTANCE_ID);
286
+ return { success: true, released_modules: modules };
287
+ });
288
+ return rel();
289
+ }
290
+
291
+ // ── Check files ───────────────────────────────────────────────────────────────
292
+
293
+ function checkFiles(db, files) {
294
+ purgeExpired(db);
295
+ const normalFiles = normalizePaths(files);
296
+ const conflicts = [];
297
+ for (const file of normalFiles) {
298
+ const lock = db.prepare(
299
+ "SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
300
+ ).get(file, INSTANCE_ID);
301
+ if (lock) conflicts.push({ file, locked_by_module: lock.module_name, locked_by_instance: lock.instance_id, expires_at: lock.expires_at });
302
+ }
303
+ return { safe: conflicts.length === 0, conflicts };
304
+ }
305
+
306
+ // ── Schema lock ───────────────────────────────────────────────────────────────
307
+
308
+ function acquireSchemaLock(db, purpose = 'migration') {
309
+ const acquire = db.transaction(() => {
310
+ purgeExpired(db);
311
+ const existing = db.prepare("SELECT * FROM schema_lock WHERE id = 1").get();
312
+ if (existing && existing.instance_id !== INSTANCE_ID) {
313
+ return { success: false, reason: `Schema locked by ${existing.instance_id} for ${existing.purpose}`, expires_at: existing.expires_at };
314
+ }
315
+ const exp = expiresAt(SCHEMA_TTL_MINUTES);
316
+ db.prepare(`
317
+ INSERT INTO schema_lock (id, instance_id, acquired_at, expires_at, purpose, pid)
318
+ VALUES (1, ?, datetime('now'), ?, ?, ?)
319
+ ON CONFLICT(id) DO UPDATE SET instance_id=excluded.instance_id,
320
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
321
+ purpose=excluded.purpose, pid=excluded.pid
322
+ `).run(INSTANCE_ID, exp, purpose, process.pid);
323
+ return { success: true, instance_id: INSTANCE_ID, purpose, expires_at: exp };
324
+ });
325
+ return acquire();
326
+ }
327
+
328
+ function releaseSchemaLock(db) {
329
+ const result = db.prepare("DELETE FROM schema_lock WHERE id = 1 AND instance_id = ?").run(INSTANCE_ID);
330
+ return { success: result.changes > 0 };
331
+ }
332
+
333
+ // ── Wait for lock ─────────────────────────────────────────────────────────────
334
+
335
+ function waitForLock(db, moduleName, timeoutSeconds = 300) {
336
+ const deadline = Date.now() + timeoutSeconds * 1000;
337
+
338
+ // Register as waiter
339
+ db.prepare(`
340
+ INSERT OR REPLACE INTO lock_waiters (module_name, instance_id, waiting_since, timeout_at)
341
+ VALUES (?, ?, datetime('now'), ?)
342
+ `).run(moduleName, INSTANCE_ID, waitTimeoutAt(timeoutSeconds));
343
+
344
+ console.log(`⏳ Waiting for [${moduleName}] to be released (timeout: ${timeoutSeconds}s)...`);
345
+
346
+ while (Date.now() < deadline) {
347
+ const existing = db.prepare(
348
+ "SELECT * FROM module_locks WHERE module_name = ? AND instance_id != ?"
349
+ ).get(moduleName, INSTANCE_ID);
350
+
351
+ if (!existing || new Date(existing.expires_at) < new Date()) {
352
+ // Lock is free — try to acquire
353
+ const result = acquireModuleLock(db, moduleName, [], 'waited');
354
+ if (result.success) {
355
+ console.log(`✅ Lock acquired after waiting: [${moduleName}]`);
356
+ return result;
357
+ }
358
+ }
359
+
360
+ // Check for deadlock while waiting
361
+ const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
362
+ if (dl.deadlock) {
363
+ db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
364
+ return { success: false, reason: 'Deadlock detected', deadlock: true, cycle: dl.deadlock_cycle };
365
+ }
366
+
367
+ // Sleep poll
368
+ const start = Date.now();
369
+ while (Date.now() - start < WAIT_POLL_MS) { /* spin */ }
370
+ }
371
+
372
+ db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
373
+ return { success: false, reason: `Timeout after ${timeoutSeconds}s waiting for [${moduleName}]` };
374
+ }
375
+
376
+ // ── Renew lock ────────────────────────────────────────────────────────────────
377
+
378
+ function renewLock(db, moduleName) {
379
+ const exp = expiresAt(LOCK_TTL_MINUTES);
380
+ const r1 = db.prepare("UPDATE module_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
381
+ .run(exp, moduleName, INSTANCE_ID);
382
+ db.prepare("UPDATE file_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
383
+ .run(exp, moduleName, INSTANCE_ID);
384
+ return { success: r1.changes > 0, renewed_until: exp };
385
+ }
386
+
387
+ // ── Status ────────────────────────────────────────────────────────────────────
388
+
389
+ function getStatus(db) {
390
+ purgeExpired(db);
391
+ return {
392
+ instance_id: INSTANCE_ID,
393
+ module_locks: db.prepare("SELECT * FROM module_locks ORDER BY acquired_at DESC").all(),
394
+ schema_lock: db.prepare("SELECT * FROM schema_lock WHERE id=1").get() || null,
395
+ waiters: db.prepare("SELECT * FROM lock_waiters").all(),
396
+ };
397
+ }
398
+
399
+ function printStatus(s) {
400
+ console.log('\n══════════════════════════════════════════════════');
401
+ console.log(' 🔒 Lock Manager v2.0 — Status');
402
+ console.log(` This instance: ${s.instance_id}`);
403
+ console.log('══════════════════════════════════════════════════');
404
+
405
+ if (s.schema_lock) {
406
+ const mine = s.schema_lock.instance_id === s.instance_id;
407
+ console.log(`\n 📐 Schema: ${mine ? '✅ YOURS' : '🔴 BLOCKED by ' + s.schema_lock.instance_id}`);
408
+ console.log(` Purpose: ${s.schema_lock.purpose} | Expires: ${s.schema_lock.expires_at}`);
409
+ } else {
410
+ console.log('\n 📐 Schema: ✅ free');
411
+ }
412
+
413
+ if (s.module_locks.length === 0) {
414
+ console.log('\n 📦 No active module locks\n');
415
+ } else {
416
+ console.log(`\n 📦 Module locks (${s.module_locks.length}):`);
417
+ for (const lock of s.module_locks) {
418
+ const mine = lock.instance_id === s.instance_id;
419
+ const files = JSON.parse(lock.files || '[]');
420
+ console.log(`\n ${mine ? '✅' : '🔴'} [${lock.module_name}] — ${mine ? 'YOURS' : 'BLOCKED'}`);
421
+ console.log(` Instance: ${lock.instance_id}`);
422
+ if (files.length) console.log(` Files: ${files.join(', ')}`);
423
+ if (lock.purpose) console.log(` Purpose: ${lock.purpose}`);
424
+ console.log(` Expires: ${lock.expires_at}`);
425
+ }
426
+ }
427
+
428
+ if (s.waiters.length > 0) {
429
+ console.log(`\n ⏳ Waiting (${s.waiters.length}):`);
430
+ for (const w of s.waiters) {
431
+ console.log(` [${w.module_name}] ← ${w.instance_id} (since ${w.waiting_since})`);
432
+ }
433
+ }
434
+
435
+ console.log('\n══════════════════════════════════════════════════\n');
436
+ }
437
+
438
+ // ── CLI ───────────────────────────────────────────────────────────────────────
439
+
440
+ if (require.main === module) {
441
+ const args = process.argv.slice(2);
442
+ const cmd = args[0];
443
+ const opts = {};
444
+ for (const arg of args.slice(1)) {
445
+ const m = arg.match(/^--?([\w-]+)(?:=(.+))?$/);
446
+ if (m) opts[m[1]] = m[2] !== undefined ? m[2] : true;
447
+ }
448
+
449
+ if (!fs.existsSync(DB_PATH)) {
450
+ console.error('Lock Manager: memoria.db not found. Run akdd init first.');
451
+ process.exit(1);
452
+ }
453
+
454
+ let db;
455
+ try { db = openDB(); ensureSchema(db); }
456
+ catch(e) { console.error('Lock Manager DB error:', e.message); process.exit(1); }
457
+
458
+ switch(cmd) {
459
+ case 'acquire': {
460
+ const mod = opts.module || opts.m;
461
+ if (!mod) { console.error('--module required'); process.exit(1); }
462
+ const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
463
+ const purpose = opts.purpose || opts.p || '';
464
+ const result = acquireModuleLock(db, mod, files, purpose);
465
+ if (result.success) {
466
+ console.log(`✅ Lock acquired: [${mod}]`);
467
+ if (files.length) console.log(` Files: ${result.files.join(', ')}`);
468
+ console.log(` Expires: ${result.expires_at}`);
469
+ } else {
470
+ console.error(`🔴 DENIED: ${result.reason}`);
471
+ if (result.deadlock) console.error(` 💀 DEADLOCK: ${result.deadlock_cycle}`);
472
+ if (result.conflicts) result.conflicts.forEach(c => console.error(` Conflict: ${c.file} → [${c.locked_by_module}]`));
473
+ process.exit(1);
474
+ }
475
+ break;
476
+ }
477
+ case 'release': {
478
+ const mod = opts.module || opts.m;
479
+ if (!mod) { console.error('--module required'); process.exit(1); }
480
+ const r = releaseModuleLock(db, mod);
481
+ if (r.success) console.log(`✅ Released: [${mod}]`);
482
+ else { console.error(`🔴 ${r.reason}`); process.exit(1); }
483
+ break;
484
+ }
485
+ case 'release-all': {
486
+ const r = releaseAll(db);
487
+ console.log(`✅ Released ${r.released_modules.length} locks: ${r.released_modules.join(', ') || '(none)'}`);
488
+ break;
489
+ }
490
+ case 'check': {
491
+ const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
492
+ if (!files.length) { console.error('--files required'); process.exit(1); }
493
+ const r = checkFiles(db, files);
494
+ if (r.safe) { console.log('✅ Files free — safe to proceed'); }
495
+ else {
496
+ console.error('🔴 Conflicts:');
497
+ r.conflicts.forEach(c => console.error(` ${c.file} → [${c.locked_by_module}]`));
498
+ process.exit(1);
499
+ }
500
+ break;
501
+ }
502
+ case 'acquire-schema': {
503
+ const r = acquireSchemaLock(db, opts.purpose || opts.p || 'migration');
504
+ if (r.success) console.log(`✅ Schema lock acquired — ${r.purpose} | Expires: ${r.expires_at}`);
505
+ else { console.error(`🔴 Schema locked: ${r.reason}`); process.exit(1); }
506
+ break;
507
+ }
508
+ case 'release-schema': {
509
+ const r = releaseSchemaLock(db);
510
+ if (r.success) console.log('✅ Schema lock released');
511
+ else console.error('⚠️ No schema lock owned by this instance');
512
+ break;
513
+ }
514
+ case 'renew': {
515
+ const mod = opts.module || opts.m;
516
+ if (!mod) { console.error('--module required'); process.exit(1); }
517
+ const r = renewLock(db, mod);
518
+ if (r.success) console.log(`✅ Renewed [${mod}] until ${r.renewed_until}`);
519
+ else { console.error('🔴 No lock to renew'); process.exit(1); }
520
+ break;
521
+ }
522
+ case 'wait': {
523
+ const mod = opts.module || opts.m;
524
+ if (!mod) { console.error('--module required'); process.exit(1); }
525
+ const timeout = parseInt(opts.timeout || '300');
526
+ const r = waitForLock(db, mod, timeout);
527
+ if (!r.success) { console.error(`🔴 ${r.reason}`); process.exit(1); }
528
+ break;
529
+ }
530
+ case 'status':
531
+ default: {
532
+ printStatus(getStatus(db));
533
+ break;
534
+ }
535
+ }
536
+ db.close();
537
+ }
538
+
539
+ module.exports = {
540
+ acquireModuleLock, releaseModuleLock, releaseAll,
541
+ acquireSchemaLock, releaseSchemaLock,
542
+ checkFiles, renewLock, waitForLock,
543
+ getStatus, detectDeadlock, normalizePath,
544
+ INSTANCE_ID,
545
+ };