chati-dev 3.2.5 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +96 -0
- package/bin/chati.js +46 -0
- package/framework/agents/build/dev.md +122 -1
- package/framework/agents/deploy/devops.md +128 -3
- package/framework/agents/discover/brief.md +77 -15
- package/framework/agents/discover/brownfield-wu.md +2 -2
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +3 -3
- package/framework/agents/plan/phases.md +127 -2
- package/framework/agents/plan/tasks.md +127 -2
- package/framework/agents/plan/ux.md +269 -22
- package/framework/agents/quality/qa-implementation.md +172 -8
- package/framework/agents/quality/qa-planning.md +147 -2
- package/framework/config.yaml +9 -5
- package/framework/constitution.md +7 -1
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +1 -1
- package/framework/hooks/constitution-guard.js +18 -2
- package/framework/hooks/mode-governance.js +3 -3
- package/framework/hooks/read-protection.js +10 -2
- package/framework/i18n/en.yaml +6 -0
- package/framework/i18n/es.yaml +6 -0
- package/framework/i18n/fr.yaml +6 -0
- package/framework/i18n/pt.yaml +6 -0
- package/framework/orchestrator/chati.md +102 -6
- package/framework/schemas/task.schema.json +1 -1
- package/framework/tasks/architect-dep-audit.md +128 -0
- package/framework/tasks/architect-stack-selection.md +28 -0
- package/framework/workflows/brownfield-fullstack.yaml +2 -2
- package/framework/workflows/brownfield-service.yaml +2 -2
- package/framework/workflows/brownfield-ui.yaml +2 -2
- package/framework/workflows/greenfield-fullstack.yaml +6 -2
- package/framework/workflows/quick-flow.yaml +7 -5
- package/framework/workflows/standard-flow.yaml +171 -0
- package/package.json +4 -2
- package/src/api/index.js +129 -0
- package/src/autonomy/build-loop.js +93 -6
- package/src/autonomy/build-state.js +20 -2
- package/src/autonomy/cause-analyzer.js +177 -0
- package/src/autonomy/escalation.js +214 -0
- package/src/autonomy/safety-net.js +23 -5
- package/src/autonomy/worktree-manager.js +245 -0
- package/src/config/agent-customizer.js +227 -0
- package/src/config/ide-configs.js +57 -27
- package/src/decision/analyzer.js +148 -0
- package/src/decision/registry-healer.js +38 -21
- package/src/extensions/loader.js +151 -0
- package/src/extensions/registry.js +134 -0
- package/src/gates/circuit-breaker.js +32 -0
- package/src/gates/g3-implementation.js +30 -4
- package/src/gates/g4-qa-implementation.js +34 -5
- package/src/gates/gate-base.js +9 -0
- package/src/health/auto-fix.js +216 -0
- package/src/installer/core.js +24 -11
- package/src/installer/provider-overlay.js +82 -0
- package/src/installer/templates.js +22 -10
- package/src/installer/transaction.js +3 -2
- package/src/installer/validator.js +74 -0
- package/src/intelligence/context-status.js +9 -5
- package/src/intelligence/document-sharder.js +221 -0
- package/src/intelligence/elicitation.js +265 -0
- package/src/intelligence/timeline.js +5 -0
- package/src/memory/gotchas.js +78 -2
- package/src/merger/semantic-merger.js +292 -0
- package/src/orchestrator/agent-selector.js +20 -0
- package/src/orchestrator/handoff-engine.js +77 -0
- package/src/orchestrator/index.js +0 -8
- package/src/orchestrator/intent-classifier.js +182 -0
- package/src/orchestrator/pipeline-manager.js +125 -1
- package/src/orchestrator/session-manager.js +164 -2
- package/src/quality/metrics-collector.js +283 -0
- package/src/quality/test-runner.js +368 -0
- package/src/telemetry/collector.js +83 -0
- package/src/telemetry/config.js +119 -0
- package/src/telemetry/index.js +11 -0
- package/src/telemetry/schema.js +104 -0
- package/src/telemetry/sender.js +60 -0
- package/src/terminal/cli-registry.js +7 -1
- package/src/terminal/cost-tracker.js +197 -0
- package/src/terminal/handoff-parser.js +61 -4
- package/src/terminal/prompt-builder.js +56 -18
- package/src/terminal/rate-limiter.js +172 -0
- package/src/terminal/run-agent.js +39 -0
- package/src/terminal/run-parallel.js +22 -1
- package/src/terminal/spawner.js +181 -3
- package/src/upgrade/migrator.js +2 -2
- package/src/utils/event-bus.js +126 -0
- package/src/utils/file-lock.js +291 -0
- package/src/utils/schema-validator.js +226 -0
- package/src/wizard/i18n.js +11 -0
- package/src/wizard/index.js +42 -20
- package/src/wizard/questions.js +200 -39
- package/src/autonomy/execution-profile.js +0 -151
- package/src/intelligence/file-tracker.js +0 -117
- package/src/memory/gotchas-auto-capture.js +0 -253
- package/src/orchestrator/pipeline-state.js +0 -223
- package/src/terminal/wave-analyzer.js +0 -143
package/src/terminal/spawner.js
CHANGED
|
@@ -11,6 +11,19 @@ import { spawn } from 'child_process';
|
|
|
11
11
|
import { validateWriteScopes, buildIsolationEnv } from './isolation.js';
|
|
12
12
|
import { getProvider } from './cli-registry.js';
|
|
13
13
|
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Constants
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Default maximum concurrent processes when spawning in parallel. */
|
|
19
|
+
export const DEFAULT_CONCURRENCY = 3;
|
|
20
|
+
|
|
21
|
+
/** Patterns that indicate a transient failure (worth retrying). */
|
|
22
|
+
export const TRANSIENT_PATTERNS = [
|
|
23
|
+
/rate limit/i, /too many requests/i, /429/, /503/,
|
|
24
|
+
/timeout/i, /ECONNRESET/, /ECONNREFUSED/,
|
|
25
|
+
];
|
|
26
|
+
|
|
14
27
|
// ---------------------------------------------------------------------------
|
|
15
28
|
// Helpers
|
|
16
29
|
// ---------------------------------------------------------------------------
|
|
@@ -150,6 +163,7 @@ export function buildSpawnCommand(config) {
|
|
|
150
163
|
// Resolve CLI provider — defaults to claude for backwards compatibility
|
|
151
164
|
const providerName = config.provider || 'claude';
|
|
152
165
|
let command, args, prompt;
|
|
166
|
+
let providerFallback = null;
|
|
153
167
|
|
|
154
168
|
try {
|
|
155
169
|
const provider = getProvider(providerName);
|
|
@@ -159,6 +173,12 @@ export function buildSpawnCommand(config) {
|
|
|
159
173
|
prompt = adapterResult.stdinPrompt;
|
|
160
174
|
} catch (err) {
|
|
161
175
|
// Fallback to claude if provider resolution fails (backwards compatibility)
|
|
176
|
+
providerFallback = {
|
|
177
|
+
requested: providerName,
|
|
178
|
+
actual: 'claude',
|
|
179
|
+
reason: err.message,
|
|
180
|
+
timestamp: new Date().toISOString(),
|
|
181
|
+
};
|
|
162
182
|
console.error(`[chati] Provider "${providerName}" resolution failed: ${err.message}. Falling back to claude.`);
|
|
163
183
|
command = 'claude';
|
|
164
184
|
args = ['--print', '--dangerously-skip-permissions'];
|
|
@@ -170,7 +190,7 @@ export function buildSpawnCommand(config) {
|
|
|
170
190
|
prompt = config.prompt || null;
|
|
171
191
|
}
|
|
172
192
|
|
|
173
|
-
return { command, args, env, terminalId, prompt };
|
|
193
|
+
return { command, args, env, terminalId, prompt, providerFallback };
|
|
174
194
|
}
|
|
175
195
|
|
|
176
196
|
/**
|
|
@@ -180,7 +200,7 @@ export function buildSpawnCommand(config) {
|
|
|
180
200
|
* @returns {TerminalHandle}
|
|
181
201
|
*/
|
|
182
202
|
export function spawnTerminal(config) {
|
|
183
|
-
const { command, args, env, terminalId, prompt } = buildSpawnCommand(config);
|
|
203
|
+
const { command, args, env, terminalId, prompt, providerFallback } = buildSpawnCommand(config);
|
|
184
204
|
|
|
185
205
|
const cwd = config.workingDir || process.cwd();
|
|
186
206
|
const timeout = config.timeout || 300_000; // default 5 minutes
|
|
@@ -204,6 +224,8 @@ export function spawnTerminal(config) {
|
|
|
204
224
|
agent: config.agent,
|
|
205
225
|
taskId: config.taskId,
|
|
206
226
|
model: config.model || 'unknown',
|
|
227
|
+
provider: config.provider || 'claude',
|
|
228
|
+
providerFallback,
|
|
207
229
|
startedAt: new Date().toISOString(),
|
|
208
230
|
status: 'running',
|
|
209
231
|
exitCode: null,
|
|
@@ -285,6 +307,96 @@ export function spawnParallelGroup(configs) {
|
|
|
285
307
|
};
|
|
286
308
|
}
|
|
287
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Spawn a group of terminals with concurrency control (pool pattern).
|
|
312
|
+
*
|
|
313
|
+
* Unlike `spawnParallelGroup` which launches all at once, this function
|
|
314
|
+
* limits the number of simultaneously running processes. When one process
|
|
315
|
+
* exits, the next in the queue is spawned.
|
|
316
|
+
*
|
|
317
|
+
* @param {SpawnConfig[]} configs
|
|
318
|
+
* @param {{ maxConcurrency?: number }} [options={}]
|
|
319
|
+
* @returns {Promise<{ groupId: string, terminals: TerminalHandle[], startedAt: string }>}
|
|
320
|
+
* @throws {Error} When write scope conflicts are detected
|
|
321
|
+
*/
|
|
322
|
+
export async function spawnParallelGroupAsync(configs, options = {}) {
|
|
323
|
+
if (!Array.isArray(configs) || configs.length === 0) {
|
|
324
|
+
throw new Error('spawnParallelGroupAsync requires a non-empty array of configs');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const maxConcurrency = options.maxConcurrency || DEFAULT_CONCURRENCY;
|
|
328
|
+
|
|
329
|
+
const validation = validateWriteScopes(configs);
|
|
330
|
+
if (!validation.valid) {
|
|
331
|
+
const details = validation.conflicts
|
|
332
|
+
.map(c => `${c.agents.join(' vs ')} on ${c.path}`)
|
|
333
|
+
.join('; ');
|
|
334
|
+
throw new Error(`Write scope conflicts detected: ${details}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const groupId = `group-${Date.now()}`;
|
|
338
|
+
const startedAt = new Date().toISOString();
|
|
339
|
+
|
|
340
|
+
// If within concurrency limit, spawn all at once (fast path)
|
|
341
|
+
if (configs.length <= maxConcurrency) {
|
|
342
|
+
const terminals = configs.map(cfg => spawnTerminal(cfg));
|
|
343
|
+
return { groupId, terminals, startedAt };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Pool pattern: spawn up to maxConcurrency, refill as processes exit
|
|
347
|
+
const terminals = [];
|
|
348
|
+
const queue = [...configs];
|
|
349
|
+
const active = new Set();
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Wait for a terminal to exit. Returns a Promise that resolves
|
|
353
|
+
* when the terminal's process emits 'exit'.
|
|
354
|
+
*/
|
|
355
|
+
function waitForExit(handle) {
|
|
356
|
+
if (handle.status !== 'running') {
|
|
357
|
+
return Promise.resolve();
|
|
358
|
+
}
|
|
359
|
+
return new Promise(resolve => {
|
|
360
|
+
if (!handle.process) {
|
|
361
|
+
resolve();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
handle.process.once('exit', () => resolve());
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Fill initial pool
|
|
369
|
+
while (queue.length > 0 && active.size < maxConcurrency) {
|
|
370
|
+
const cfg = queue.shift();
|
|
371
|
+
const handle = spawnTerminal(cfg);
|
|
372
|
+
terminals.push(handle);
|
|
373
|
+
active.add(handle);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Process remaining queue as slots free up
|
|
377
|
+
while (queue.length > 0) {
|
|
378
|
+
// Wait for ANY active process to exit
|
|
379
|
+
await Promise.race([...active].map(h => waitForExit(h)));
|
|
380
|
+
|
|
381
|
+
// Remove exited processes from active set
|
|
382
|
+
for (const h of active) {
|
|
383
|
+
if (h.status !== 'running') {
|
|
384
|
+
active.delete(h);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Fill freed slots
|
|
389
|
+
while (queue.length > 0 && active.size < maxConcurrency) {
|
|
390
|
+
const cfg = queue.shift();
|
|
391
|
+
const handle = spawnTerminal(cfg);
|
|
392
|
+
terminals.push(handle);
|
|
393
|
+
active.add(handle);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { groupId, terminals, startedAt };
|
|
398
|
+
}
|
|
399
|
+
|
|
288
400
|
/**
|
|
289
401
|
* Gracefully kill a spawned terminal.
|
|
290
402
|
* Sends SIGTERM first; if the process is still alive after 5 seconds,
|
|
@@ -336,7 +448,7 @@ export function killTerminal(handle) {
|
|
|
336
448
|
*/
|
|
337
449
|
export function getTerminalStatus(handle) {
|
|
338
450
|
if (!handle) {
|
|
339
|
-
return { id: 'unknown', agent: 'unknown', model: 'unknown', status: 'unknown', elapsed: 0, exitCode: null };
|
|
451
|
+
return { id: 'unknown', agent: 'unknown', model: 'unknown', provider: 'unknown', status: 'unknown', elapsed: 0, exitCode: null, providerFallback: null };
|
|
340
452
|
}
|
|
341
453
|
|
|
342
454
|
const elapsed = Date.now() - new Date(handle.startedAt).getTime();
|
|
@@ -345,8 +457,74 @@ export function getTerminalStatus(handle) {
|
|
|
345
457
|
id: handle.id,
|
|
346
458
|
agent: handle.agent,
|
|
347
459
|
model: handle.model || 'unknown',
|
|
460
|
+
provider: handle.provider || 'unknown',
|
|
348
461
|
status: handle.status,
|
|
349
462
|
elapsed,
|
|
350
463
|
exitCode: handle.exitCode,
|
|
464
|
+
providerFallback: handle.providerFallback || null,
|
|
351
465
|
};
|
|
352
466
|
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Retry / Backoff
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Determine whether a terminal failure is transient (worth retrying).
|
|
474
|
+
*
|
|
475
|
+
* @param {number} exitCode - Process exit code
|
|
476
|
+
* @param {string[]|string} stderr - Captured stderr output
|
|
477
|
+
* @returns {boolean}
|
|
478
|
+
*/
|
|
479
|
+
export function isTransientFailure(exitCode, stderr) {
|
|
480
|
+
if (exitCode === 0) return false;
|
|
481
|
+
const str = Array.isArray(stderr) ? stderr.join('') : (stderr || '');
|
|
482
|
+
return TRANSIENT_PATTERNS.some(p => p.test(str));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Spawn a terminal with automatic retry on transient failures.
|
|
487
|
+
*
|
|
488
|
+
* Wraps `spawnTerminal()` with exponential backoff.
|
|
489
|
+
* Non-transient failures are returned immediately without retry.
|
|
490
|
+
*
|
|
491
|
+
* @param {SpawnConfig} config
|
|
492
|
+
* @param {{ maxRetries?: number, baseDelay?: number, shouldRetry?: function }} [retryOptions={}]
|
|
493
|
+
* @returns {Promise<TerminalHandle>}
|
|
494
|
+
*/
|
|
495
|
+
export async function spawnTerminalWithRetry(config, retryOptions = {}) {
|
|
496
|
+
const maxRetries = retryOptions.maxRetries ?? 2;
|
|
497
|
+
const baseDelay = retryOptions.baseDelay ?? 2000;
|
|
498
|
+
const shouldRetry = retryOptions.shouldRetry || isTransientFailure;
|
|
499
|
+
|
|
500
|
+
let lastHandle = null;
|
|
501
|
+
|
|
502
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
503
|
+
const handle = spawnTerminal(config);
|
|
504
|
+
lastHandle = handle;
|
|
505
|
+
|
|
506
|
+
// Wait for process to exit
|
|
507
|
+
await new Promise((resolve) => {
|
|
508
|
+
if (!handle.process) { resolve(); return; }
|
|
509
|
+
if (handle.status !== 'running') { resolve(); return; }
|
|
510
|
+
handle.process.once('exit', () => resolve());
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Success — return immediately
|
|
514
|
+
if (handle.exitCode === 0) {
|
|
515
|
+
return handle;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Check if failure is transient and retries remain
|
|
519
|
+
if (attempt < maxRetries && shouldRetry(handle.exitCode, handle.stderr)) {
|
|
520
|
+
const delay = baseDelay * Math.pow(2, attempt);
|
|
521
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Non-transient or out of retries — return as-is
|
|
526
|
+
return handle;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return lastHandle;
|
|
530
|
+
}
|
package/src/upgrade/migrator.js
CHANGED
|
@@ -149,8 +149,8 @@ export async function runMigrations(targetDir, fromVersion, toVersion) {
|
|
|
149
149
|
for (const step of migration.rollback) {
|
|
150
150
|
executeMigrationStep(targetDir, step);
|
|
151
151
|
}
|
|
152
|
-
} catch {
|
|
153
|
-
|
|
152
|
+
} catch (rollbackErr) {
|
|
153
|
+
console.error('[chati] migration rollback failed:', rollbackErr?.message);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
return {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Singleton event bus for the chati.dev framework.
|
|
3
|
+
*
|
|
4
|
+
* Provides a centralized EventEmitter for observing state changes
|
|
5
|
+
* across subsystems (build-loop, spawner, pipeline, safety-net).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @deprecated Not currently imported by any production module.
|
|
10
|
+
* Retained for potential future integration. Remove if still unused by v4.0.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Event Names
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* All framework event names, organized by subsystem.
|
|
21
|
+
*/
|
|
22
|
+
export const EVENTS = {
|
|
23
|
+
// Build loop
|
|
24
|
+
BUILD_STARTED: 'build:started',
|
|
25
|
+
BUILD_COMPLETED: 'build:completed',
|
|
26
|
+
BUILD_FAILED: 'build:failed',
|
|
27
|
+
TASK_STARTED: 'build:task:started',
|
|
28
|
+
TASK_COMPLETED: 'build:task:completed',
|
|
29
|
+
TASK_FAILED: 'build:task:failed',
|
|
30
|
+
TASK_EXHAUSTED: 'build:task:exhausted',
|
|
31
|
+
|
|
32
|
+
// Spawner
|
|
33
|
+
TERMINAL_SPAWNED: 'terminal:spawned',
|
|
34
|
+
TERMINAL_EXITED: 'terminal:exited',
|
|
35
|
+
TERMINAL_KILLED: 'terminal:killed',
|
|
36
|
+
|
|
37
|
+
// Pipeline
|
|
38
|
+
PHASE_ADVANCED: 'pipeline:phase:advanced',
|
|
39
|
+
AGENT_COMPLETED: 'pipeline:agent:completed',
|
|
40
|
+
QA_GATE_PASSED: 'pipeline:qa:passed',
|
|
41
|
+
QA_GATE_FAILED: 'pipeline:qa:failed',
|
|
42
|
+
|
|
43
|
+
// Safety
|
|
44
|
+
SAFETY_TRIGGERED: 'safety:triggered',
|
|
45
|
+
|
|
46
|
+
// Health
|
|
47
|
+
HEALTH_CHECK_COMPLETED: 'health:completed',
|
|
48
|
+
|
|
49
|
+
// Escalation
|
|
50
|
+
ESCALATION_TRIGGERED: 'escalation:triggered',
|
|
51
|
+
ESCALATION_PAUSED: 'escalation:paused',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Bus Instance
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const bus = new EventEmitter();
|
|
59
|
+
bus.setMaxListeners(50);
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Public API
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Emit an event with data. Automatically adds a timestamp.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} event - Event name from EVENTS
|
|
69
|
+
* @param {object} [data={}] - Event payload
|
|
70
|
+
*/
|
|
71
|
+
export function emit(event, data = {}) {
|
|
72
|
+
bus.emit(event, { ...data, timestamp: Date.now() });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Subscribe to an event.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} event - Event name
|
|
79
|
+
* @param {function} handler - Event handler
|
|
80
|
+
*/
|
|
81
|
+
export function on(event, handler) {
|
|
82
|
+
bus.on(event, handler);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Subscribe to an event once.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} event - Event name
|
|
89
|
+
* @param {function} handler - Event handler
|
|
90
|
+
*/
|
|
91
|
+
export function once(event, handler) {
|
|
92
|
+
bus.once(event, handler);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Unsubscribe from an event.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} event - Event name
|
|
99
|
+
* @param {function} handler - Event handler to remove
|
|
100
|
+
*/
|
|
101
|
+
export function off(event, handler) {
|
|
102
|
+
bus.off(event, handler);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remove all listeners for an event, or all events if no event specified.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} [event] - Event name (optional)
|
|
109
|
+
*/
|
|
110
|
+
export function removeAllListeners(event) {
|
|
111
|
+
if (event) {
|
|
112
|
+
bus.removeAllListeners(event);
|
|
113
|
+
} else {
|
|
114
|
+
bus.removeAllListeners();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get the count of listeners for an event.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} event - Event name
|
|
122
|
+
* @returns {number}
|
|
123
|
+
*/
|
|
124
|
+
export function listenerCount(event) {
|
|
125
|
+
return bus.listenerCount(event);
|
|
126
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Advisory file locking using .lock files + PID.
|
|
3
|
+
*
|
|
4
|
+
* Prevents concurrent writes to shared state files (session.yaml,
|
|
5
|
+
* build-state.json) during multi-terminal execution.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
|
9
|
+
import { dirname } from 'path';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Constants
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Default lock acquisition timeout in ms */
|
|
16
|
+
export const DEFAULT_TIMEOUT = 5000;
|
|
17
|
+
|
|
18
|
+
/** Default retry interval in ms */
|
|
19
|
+
export const DEFAULT_RETRY_INTERVAL = 100;
|
|
20
|
+
|
|
21
|
+
/** Default stale threshold in ms (30 seconds) */
|
|
22
|
+
export const STALE_THRESHOLD_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Lock Management
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the lock file path for a given file.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} filePath
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function getLockPath(filePath) {
|
|
35
|
+
return `${filePath}.lock`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if a process is still running.
|
|
40
|
+
*
|
|
41
|
+
* @param {number} pid
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function isProcessAlive(pid) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read lock file contents.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} lockPath
|
|
57
|
+
* @returns {{ pid: number, timestamp: number, hostname: string }|null}
|
|
58
|
+
*/
|
|
59
|
+
function readLockFile(lockPath) {
|
|
60
|
+
try {
|
|
61
|
+
const content = readFileSync(lockPath, 'utf-8');
|
|
62
|
+
return JSON.parse(content);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write lock file with current process info.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} lockPath
|
|
72
|
+
*/
|
|
73
|
+
function writeLockFile(lockPath) {
|
|
74
|
+
const dir = dirname(lockPath);
|
|
75
|
+
if (!existsSync(dir)) {
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const lockData = {
|
|
80
|
+
pid: process.pid,
|
|
81
|
+
timestamp: Date.now(),
|
|
82
|
+
hostname: typeof globalThis !== 'undefined' ? (globalThis.process?.env?.HOSTNAME || 'localhost') : 'localhost',
|
|
83
|
+
};
|
|
84
|
+
writeFileSync(lockPath, JSON.stringify(lockData), 'utf-8');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if a lock is stale (owner process dead or lock too old).
|
|
89
|
+
*
|
|
90
|
+
* @param {string} lockPath
|
|
91
|
+
* @param {object} [options]
|
|
92
|
+
* @param {boolean} [options.stalePidCheck=true]
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function isLockStale(lockPath, options = {}) {
|
|
96
|
+
const { stalePidCheck = true } = options;
|
|
97
|
+
const lockData = readLockFile(lockPath);
|
|
98
|
+
|
|
99
|
+
if (!lockData) return true;
|
|
100
|
+
|
|
101
|
+
// Check if lock is too old
|
|
102
|
+
if (Date.now() - lockData.timestamp > STALE_THRESHOLD_MS) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if owner process is still alive
|
|
107
|
+
if (stalePidCheck && lockData.pid && !isProcessAlive(lockData.pid)) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Acquire an advisory lock on a file.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} filePath - Path to the file to lock
|
|
118
|
+
* @param {object} [options]
|
|
119
|
+
* @param {number} [options.timeout=5000] - Max time to wait for lock
|
|
120
|
+
* @param {number} [options.retryInterval=100] - Time between retries
|
|
121
|
+
* @param {boolean} [options.stalePidCheck=true] - Check if lock owner PID is alive
|
|
122
|
+
* @returns {{ acquired: boolean, lockPath: string, release: () => void }}
|
|
123
|
+
*/
|
|
124
|
+
export function acquireLock(filePath, options = {}) {
|
|
125
|
+
const {
|
|
126
|
+
timeout = DEFAULT_TIMEOUT,
|
|
127
|
+
retryInterval = DEFAULT_RETRY_INTERVAL,
|
|
128
|
+
stalePidCheck = true,
|
|
129
|
+
} = options;
|
|
130
|
+
|
|
131
|
+
const lockPath = getLockPath(filePath);
|
|
132
|
+
const startTime = Date.now();
|
|
133
|
+
|
|
134
|
+
while (true) {
|
|
135
|
+
// If lock file doesn't exist, acquire it
|
|
136
|
+
if (!existsSync(lockPath)) {
|
|
137
|
+
writeLockFile(lockPath);
|
|
138
|
+
return {
|
|
139
|
+
acquired: true,
|
|
140
|
+
lockPath,
|
|
141
|
+
release: () => releaseLock(lockPath),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If lock is stale, remove and acquire
|
|
146
|
+
if (isLockStale(lockPath, { stalePidCheck })) {
|
|
147
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
148
|
+
writeLockFile(lockPath);
|
|
149
|
+
return {
|
|
150
|
+
acquired: true,
|
|
151
|
+
lockPath,
|
|
152
|
+
release: () => releaseLock(lockPath),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check timeout
|
|
157
|
+
if (Date.now() - startTime >= timeout) {
|
|
158
|
+
return {
|
|
159
|
+
acquired: false,
|
|
160
|
+
lockPath,
|
|
161
|
+
release: () => {},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Busy wait (synchronous - acceptable for short lock durations)
|
|
166
|
+
const waitUntil = Date.now() + retryInterval;
|
|
167
|
+
while (Date.now() < waitUntil) { /* spin */ }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Release an advisory lock.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} lockPath - Path to the lock file
|
|
175
|
+
*/
|
|
176
|
+
export function releaseLock(lockPath) {
|
|
177
|
+
try {
|
|
178
|
+
if (existsSync(lockPath)) {
|
|
179
|
+
const lockData = readLockFile(lockPath);
|
|
180
|
+
// Only release if we own it
|
|
181
|
+
if (!lockData || lockData.pid === process.pid) {
|
|
182
|
+
unlinkSync(lockPath);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Lock may already be released — ignore
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Check if a file is currently locked.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} filePath
|
|
194
|
+
* @returns {boolean}
|
|
195
|
+
*/
|
|
196
|
+
export function isLocked(filePath) {
|
|
197
|
+
const lockPath = getLockPath(filePath);
|
|
198
|
+
if (!existsSync(lockPath)) return false;
|
|
199
|
+
return !isLockStale(lockPath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Execute a function while holding a lock on a file.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} filePath - File to lock
|
|
206
|
+
* @param {function(): T|Promise<T>} fn - Function to execute while holding lock
|
|
207
|
+
* @param {object} [options] - Lock options
|
|
208
|
+
* @returns {Promise<T>}
|
|
209
|
+
* @template T
|
|
210
|
+
*/
|
|
211
|
+
export async function withLock(filePath, fn, options = {}) {
|
|
212
|
+
const lock = acquireLock(filePath, options);
|
|
213
|
+
|
|
214
|
+
if (!lock.acquired) {
|
|
215
|
+
throw new Error(`Failed to acquire lock on ${filePath}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
return await fn();
|
|
220
|
+
} finally {
|
|
221
|
+
lock.release();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Acquire a user-aware lock on a file.
|
|
227
|
+
* Only the same userId can re-acquire an existing lock.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} filePath - File to lock
|
|
230
|
+
* @param {string} userId - User identifier
|
|
231
|
+
* @param {object} [options] - Lock options
|
|
232
|
+
* @param {number} [options.timeout=5000] - Max time to wait
|
|
233
|
+
* @param {number} [options.retryInterval=100] - Time between retries
|
|
234
|
+
* @returns {{ acquired: boolean, lockPath: string, owner: string|null, release: () => void }}
|
|
235
|
+
*/
|
|
236
|
+
export function acquireUserLock(filePath, userId, options = {}) {
|
|
237
|
+
if (!userId) {
|
|
238
|
+
return {
|
|
239
|
+
acquired: false,
|
|
240
|
+
lockPath: `${filePath}.lock`,
|
|
241
|
+
owner: null,
|
|
242
|
+
release: () => {},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const lockPath = `${filePath}.lock`;
|
|
247
|
+
|
|
248
|
+
// Check if another user holds the lock
|
|
249
|
+
if (existsSync(lockPath)) {
|
|
250
|
+
const lockData = readLockFile(lockPath);
|
|
251
|
+
|
|
252
|
+
if (lockData && lockData.userId && lockData.userId !== userId) {
|
|
253
|
+
// Check if the lock is stale
|
|
254
|
+
if (!isLockStale(lockPath, options)) {
|
|
255
|
+
return {
|
|
256
|
+
acquired: false,
|
|
257
|
+
lockPath,
|
|
258
|
+
owner: lockData.userId,
|
|
259
|
+
release: () => {},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// Stale lock — remove and continue
|
|
263
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Use standard lock acquisition
|
|
268
|
+
const lock = acquireLock(filePath, options);
|
|
269
|
+
|
|
270
|
+
if (lock.acquired) {
|
|
271
|
+
// Overwrite lock file with user info
|
|
272
|
+
const dir = dirname(lockPath);
|
|
273
|
+
if (!existsSync(dir)) {
|
|
274
|
+
mkdirSync(dir, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
const lockData = {
|
|
277
|
+
pid: process.pid,
|
|
278
|
+
timestamp: Date.now(),
|
|
279
|
+
userId,
|
|
280
|
+
hostname: typeof globalThis !== 'undefined' ? (globalThis.process?.env?.HOSTNAME || 'localhost') : 'localhost',
|
|
281
|
+
};
|
|
282
|
+
writeFileSync(lockPath, JSON.stringify(lockData), 'utf-8');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
acquired: lock.acquired,
|
|
287
|
+
lockPath: lock.lockPath,
|
|
288
|
+
owner: userId,
|
|
289
|
+
release: lock.release,
|
|
290
|
+
};
|
|
291
|
+
}
|