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.
- package/CLAUDE.md +80 -55
- package/README.md +120 -35
- package/akdd-analyze.cjs +319 -0
- package/bin/akdd.js +8 -0
- package/lock-manager.cjs +545 -0
- package/mem-curator.cjs +290 -513
- package/package.json +1 -1
- package/src/onboard.js +312 -0
- package/src/update.js +154 -33
- package/tdd-gate.cjs +4 -0
- package/templates/.agentic/DESIGN_SYSTEM.fastapi.md +109 -0
- package/templates/.agentic/DESIGN_SYSTEM.nextjs.md +91 -0
- package/templates/.agentic/grafo/akdd-analyze.cjs +319 -0
- package/templates/.agentic/grafo/grafo.cjs +696 -2
- package/templates/.agentic/grafo/lock-manager.cjs +545 -0
- package/templates/.agentic/grafo/mem-curator.cjs +361 -0
|
@@ -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
|
+
};
|