memorix 1.1.7 → 1.1.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/CHANGELOG.md +13 -0
- package/CLAUDE.md +6 -1
- package/README.md +21 -0
- package/README.zh-CN.md +21 -0
- package/TEAM.md +86 -86
- package/dist/cli/index.js +730 -150
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/index.html +201 -201
- package/dist/dashboard/static/style.css +3584 -3584
- package/dist/index.js +15 -8
- package/dist/index.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +13 -0
- package/dist/memcode-runtime/package.json +4 -4
- package/dist/sdk.js +15 -8
- package/dist/sdk.js.map +1 -1
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +18 -0
- package/docs/DESIGN_DECISIONS.md +357 -357
- package/docs/SETUP.md +10 -0
- package/docs/dev-log/progress.txt +35 -26
- package/package.json +1 -1
- package/src/audit/index.ts +156 -156
- package/src/cli/commands/agent-integrations.ts +623 -0
- package/src/cli/commands/audit-list.ts +89 -89
- package/src/cli/commands/background.ts +659 -659
- package/src/cli/commands/cleanup.ts +255 -255
- package/src/cli/commands/doctor.ts +23 -0
- package/src/cli/commands/formation.ts +48 -48
- package/src/cli/commands/git-hook-install.ts +111 -111
- package/src/cli/commands/handoff.ts +66 -66
- package/src/cli/commands/hooks-status.ts +63 -63
- package/src/cli/commands/ingest-commit.ts +153 -153
- package/src/cli/commands/ingest-image.ts +73 -73
- package/src/cli/commands/ingest-log.ts +180 -180
- package/src/cli/commands/ingest.ts +44 -44
- package/src/cli/commands/integrate-shared.ts +15 -15
- package/src/cli/commands/lock.ts +96 -96
- package/src/cli/commands/message.ts +121 -121
- package/src/cli/commands/poll.ts +70 -70
- package/src/cli/commands/purge-all-memory.ts +85 -85
- package/src/cli/commands/purge-project-memory.ts +83 -83
- package/src/cli/commands/reasoning.ts +132 -132
- package/src/cli/commands/repair.ts +60 -0
- package/src/cli/commands/retention.ts +108 -108
- package/src/cli/commands/serve-shared.ts +118 -118
- package/src/cli/commands/setup.ts +3 -3
- package/src/cli/commands/skills.ts +123 -123
- package/src/cli/commands/task.ts +192 -192
- package/src/cli/commands/transfer.ts +73 -73
- package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
- package/src/cli/index.ts +3 -1
- package/src/cli/tui/ChatView.tsx +234 -234
- package/src/cli/tui/CommandBar.tsx +312 -312
- package/src/cli/tui/ContextRail.tsx +118 -118
- package/src/cli/tui/HeaderBar.tsx +72 -72
- package/src/cli/tui/LogoBanner.tsx +51 -51
- package/src/cli/tui/Panels.tsx +632 -632
- package/src/cli/tui/Sidebar.tsx +179 -179
- package/src/cli/tui/chat-service.ts +742 -742
- package/src/cli/tui/data.ts +547 -547
- package/src/cli/tui/index.ts +41 -41
- package/src/cli/tui/markdown-render.tsx +371 -371
- package/src/cli/tui/theme.ts +178 -178
- package/src/cli/tui/use-mouse.ts +157 -157
- package/src/cli/tui/useNavigation.ts +56 -56
- package/src/cli/update-checker.ts +211 -211
- package/src/cli/version.ts +7 -7
- package/src/cli/workbench.ts +1 -1
- package/src/codegraph/context-pack.ts +5 -1
- package/src/codegraph/lite-provider.ts +5 -1
- package/src/codegraph/project-context.ts +5 -1
- package/src/compact/token-budget.ts +74 -74
- package/src/config/behavior.ts +59 -59
- package/src/dashboard/project-classification.ts +64 -64
- package/src/dashboard/static/index.html +201 -201
- package/src/dashboard/static/style.css +3584 -3584
- package/src/embedding/fastembed-provider.ts +142 -142
- package/src/embedding/transformers-provider.ts +111 -111
- package/src/git/extractor.ts +209 -209
- package/src/git/hooks-path.ts +85 -85
- package/src/git/noise-filter.ts +210 -210
- package/src/hooks/installers/index.ts +4 -4
- package/src/hooks/official-skills.ts +1 -1
- package/src/hooks/pattern-detector.ts +173 -173
- package/src/hooks/rules/memorix-agent-rules.md +2 -2
- package/src/hooks/significance-filter.ts +250 -250
- package/src/llm/memory-manager.ts +328 -328
- package/src/llm/provider.ts +885 -885
- package/src/llm/quality.ts +248 -248
- package/src/memory/attribution-guard.ts +249 -249
- package/src/memory/auto-relations.ts +107 -107
- package/src/memory/consolidation.ts +302 -302
- package/src/memory/disclosure-policy.ts +141 -141
- package/src/memory/entity-extractor.ts +197 -197
- package/src/memory/formation/evaluate.ts +217 -217
- package/src/memory/formation/extract.ts +361 -361
- package/src/memory/formation/index.ts +417 -417
- package/src/memory/formation/resolve.ts +344 -344
- package/src/memory/formation/types.ts +315 -315
- package/src/memory/freshness.ts +122 -122
- package/src/memory/graph.ts +197 -197
- package/src/memory/refs.ts +94 -94
- package/src/memory/retention.ts +433 -433
- package/src/memory/secret-filter.ts +79 -79
- package/src/memory/session.ts +523 -523
- package/src/multimodal/image-loader.ts +143 -143
- package/src/orchestrate/adapters/claude-stream.ts +192 -192
- package/src/orchestrate/adapters/claude.ts +111 -111
- package/src/orchestrate/adapters/codex-stream.ts +134 -134
- package/src/orchestrate/adapters/codex.ts +41 -41
- package/src/orchestrate/adapters/gemini-stream.ts +166 -166
- package/src/orchestrate/adapters/gemini.ts +42 -42
- package/src/orchestrate/adapters/index.ts +73 -73
- package/src/orchestrate/adapters/opencode-stream.ts +143 -143
- package/src/orchestrate/adapters/opencode.ts +47 -47
- package/src/orchestrate/adapters/spawn-helper.ts +286 -286
- package/src/orchestrate/adapters/types.ts +77 -77
- package/src/orchestrate/capability-router.ts +284 -284
- package/src/orchestrate/context-compact.ts +188 -188
- package/src/orchestrate/cost-tracker.ts +219 -219
- package/src/orchestrate/error-recovery.ts +191 -191
- package/src/orchestrate/evidence.ts +140 -140
- package/src/orchestrate/ledger.ts +110 -110
- package/src/orchestrate/memorix-bridge.ts +380 -380
- package/src/orchestrate/output-budget.ts +80 -80
- package/src/orchestrate/permission.ts +152 -152
- package/src/orchestrate/pipeline-trace.ts +131 -131
- package/src/orchestrate/prompt-builder.ts +155 -155
- package/src/orchestrate/ring-buffer.ts +37 -37
- package/src/orchestrate/task-graph.ts +389 -389
- package/src/orchestrate/verify-gate.ts +219 -219
- package/src/orchestrate/worktree.ts +232 -232
- package/src/project/aliases.ts +374 -374
- package/src/project/detector.ts +268 -268
- package/src/rules/adapters/claude-code.ts +99 -99
- package/src/rules/adapters/codex.ts +97 -97
- package/src/rules/adapters/copilot.ts +124 -124
- package/src/rules/adapters/cursor.ts +114 -114
- package/src/rules/adapters/kiro.ts +126 -126
- package/src/rules/adapters/trae.ts +56 -56
- package/src/rules/adapters/windsurf.ts +83 -83
- package/src/rules/syncer.ts +235 -235
- package/src/sdk.ts +327 -327
- package/src/search/intent-detector.ts +289 -289
- package/src/search/query-expansion.ts +52 -52
- package/src/server/formation-timeout.ts +27 -27
- package/src/skills/mini-skills.ts +386 -386
- package/src/store/chat-store.ts +119 -119
- package/src/store/file-lock.ts +100 -100
- package/src/store/graph-store.ts +249 -249
- package/src/store/mini-skill-store.ts +349 -349
- package/src/store/obs-store.ts +255 -255
- package/src/store/persistence-json.ts +212 -212
- package/src/store/persistence.ts +291 -291
- package/src/store/project-affinity.ts +195 -195
- package/src/store/session-store.ts +259 -259
- package/src/store/sqlite-store.ts +339 -339
- package/src/team/event-bus.ts +76 -76
- package/src/team/file-locks.ts +173 -173
- package/src/team/handoff.ts +167 -167
- package/src/team/messages.ts +203 -203
- package/src/team/poll.ts +132 -132
- package/src/team/tasks.ts +211 -211
- package/src/wiki/generator.ts +237 -237
- package/src/wiki/knowledge-graph.ts +334 -334
- package/src/wiki/types.ts +85 -85
- package/src/workspace/mcp-adapters/codex.ts +191 -191
- package/src/workspace/mcp-adapters/copilot.ts +105 -105
- package/src/workspace/mcp-adapters/cursor.ts +53 -53
- package/src/workspace/mcp-adapters/kiro.ts +64 -64
- package/src/workspace/mcp-adapters/opencode.ts +123 -123
- package/src/workspace/mcp-adapters/trae.ts +134 -134
- package/src/workspace/mcp-adapters/windsurf.ts +91 -91
- package/src/workspace/sanitizer.ts +60 -60
- package/src/workspace/workflow-sync.ts +131 -131
package/src/team/event-bus.ts
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TeamEventBus — Process-local typed event emitter for team coordination.
|
|
3
|
-
*
|
|
4
|
-
* Phase 4b: Provides push-based acceleration within a single process.
|
|
5
|
-
* NOT a cross-process mechanism — the orchestrator uses SQLite polling.
|
|
6
|
-
*
|
|
7
|
-
* Architectural boundary (B1): This is process-local fanout only.
|
|
8
|
-
* No persistence, no cross-process delivery, no durability guarantees.
|
|
9
|
-
* If no listeners are registered, events are silently dropped.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { EventEmitter } from 'node:events';
|
|
13
|
-
|
|
14
|
-
// ── Event type map ─────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
export interface TeamEventMap {
|
|
17
|
-
'task:created': { taskId: string; projectId: string; description: string };
|
|
18
|
-
'task:claimed': { taskId: string; projectId: string; agentId: string };
|
|
19
|
-
'task:completed': { taskId: string; projectId: string; agentId: string; result?: string };
|
|
20
|
-
'task:failed': { taskId: string; projectId: string; agentId: string; result?: string };
|
|
21
|
-
'task:released': { taskId: string; projectId: string; agentId: string };
|
|
22
|
-
'agent:joined': { agentId: string; projectId: string; agentType: string };
|
|
23
|
-
'agent:left': { agentId: string; projectId: string };
|
|
24
|
-
'agent:stale': { agentId: string; projectId: string; releasedTasks: number };
|
|
25
|
-
'handoff:created':{ observationId: number; projectId: string; fromAgent: string; toAgent?: string; taskId?: string };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export type TeamEventName = keyof TeamEventMap;
|
|
29
|
-
|
|
30
|
-
// ── TeamEventBus ───────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
export class TeamEventBus {
|
|
33
|
-
private emitter = new EventEmitter();
|
|
34
|
-
|
|
35
|
-
constructor() {
|
|
36
|
-
// Prevent memory leak warnings for legitimate multi-listener scenarios
|
|
37
|
-
this.emitter.setMaxListeners(50);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Emit a typed event. Non-blocking, best-effort.
|
|
42
|
-
* If no listeners are registered, the event is silently dropped.
|
|
43
|
-
*/
|
|
44
|
-
emit<K extends TeamEventName>(event: K, data: TeamEventMap[K]): void {
|
|
45
|
-
try {
|
|
46
|
-
this.emitter.emit(event, data);
|
|
47
|
-
} catch {
|
|
48
|
-
// Best-effort: listener errors never propagate to caller
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Subscribe to a typed event. Returns unsubscribe function. */
|
|
53
|
-
on<K extends TeamEventName>(event: K, listener: (data: TeamEventMap[K]) => void): () => void {
|
|
54
|
-
this.emitter.on(event, listener);
|
|
55
|
-
return () => { this.emitter.off(event, listener); };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Subscribe to a typed event, fire only once. */
|
|
59
|
-
once<K extends TeamEventName>(event: K, listener: (data: TeamEventMap[K]) => void): void {
|
|
60
|
-
this.emitter.once(event, listener);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Remove all listeners for a specific event, or all events if none specified. */
|
|
64
|
-
removeAllListeners(event?: TeamEventName): void {
|
|
65
|
-
if (event) {
|
|
66
|
-
this.emitter.removeAllListeners(event);
|
|
67
|
-
} else {
|
|
68
|
-
this.emitter.removeAllListeners();
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Current listener count for a specific event. */
|
|
73
|
-
listenerCount(event: TeamEventName): number {
|
|
74
|
-
return this.emitter.listenerCount(event);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* TeamEventBus — Process-local typed event emitter for team coordination.
|
|
3
|
+
*
|
|
4
|
+
* Phase 4b: Provides push-based acceleration within a single process.
|
|
5
|
+
* NOT a cross-process mechanism — the orchestrator uses SQLite polling.
|
|
6
|
+
*
|
|
7
|
+
* Architectural boundary (B1): This is process-local fanout only.
|
|
8
|
+
* No persistence, no cross-process delivery, no durability guarantees.
|
|
9
|
+
* If no listeners are registered, events are silently dropped.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { EventEmitter } from 'node:events';
|
|
13
|
+
|
|
14
|
+
// ── Event type map ─────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface TeamEventMap {
|
|
17
|
+
'task:created': { taskId: string; projectId: string; description: string };
|
|
18
|
+
'task:claimed': { taskId: string; projectId: string; agentId: string };
|
|
19
|
+
'task:completed': { taskId: string; projectId: string; agentId: string; result?: string };
|
|
20
|
+
'task:failed': { taskId: string; projectId: string; agentId: string; result?: string };
|
|
21
|
+
'task:released': { taskId: string; projectId: string; agentId: string };
|
|
22
|
+
'agent:joined': { agentId: string; projectId: string; agentType: string };
|
|
23
|
+
'agent:left': { agentId: string; projectId: string };
|
|
24
|
+
'agent:stale': { agentId: string; projectId: string; releasedTasks: number };
|
|
25
|
+
'handoff:created':{ observationId: number; projectId: string; fromAgent: string; toAgent?: string; taskId?: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TeamEventName = keyof TeamEventMap;
|
|
29
|
+
|
|
30
|
+
// ── TeamEventBus ───────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export class TeamEventBus {
|
|
33
|
+
private emitter = new EventEmitter();
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
// Prevent memory leak warnings for legitimate multi-listener scenarios
|
|
37
|
+
this.emitter.setMaxListeners(50);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Emit a typed event. Non-blocking, best-effort.
|
|
42
|
+
* If no listeners are registered, the event is silently dropped.
|
|
43
|
+
*/
|
|
44
|
+
emit<K extends TeamEventName>(event: K, data: TeamEventMap[K]): void {
|
|
45
|
+
try {
|
|
46
|
+
this.emitter.emit(event, data);
|
|
47
|
+
} catch {
|
|
48
|
+
// Best-effort: listener errors never propagate to caller
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Subscribe to a typed event. Returns unsubscribe function. */
|
|
53
|
+
on<K extends TeamEventName>(event: K, listener: (data: TeamEventMap[K]) => void): () => void {
|
|
54
|
+
this.emitter.on(event, listener);
|
|
55
|
+
return () => { this.emitter.off(event, listener); };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Subscribe to a typed event, fire only once. */
|
|
59
|
+
once<K extends TeamEventName>(event: K, listener: (data: TeamEventMap[K]) => void): void {
|
|
60
|
+
this.emitter.once(event, listener);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Remove all listeners for a specific event, or all events if none specified. */
|
|
64
|
+
removeAllListeners(event?: TeamEventName): void {
|
|
65
|
+
if (event) {
|
|
66
|
+
this.emitter.removeAllListeners(event);
|
|
67
|
+
} else {
|
|
68
|
+
this.emitter.removeAllListeners();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Current listener count for a specific event. */
|
|
73
|
+
listenerCount(event: TeamEventName): number {
|
|
74
|
+
return this.emitter.listenerCount(event);
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/team/file-locks.ts
CHANGED
|
@@ -1,173 +1,173 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File Lock Registry — Advisory file locks for multi-agent coordination
|
|
3
|
-
*
|
|
4
|
-
* Prevents conflicting edits when multiple agents work on the same project.
|
|
5
|
-
* Locks are advisory (not enforced at OS level) with TTL auto-release.
|
|
6
|
-
* Default TTL: 10 minutes. Agents see lock status via session_start injection.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
// ─── Types ───────────────────────────────────────────────────────────
|
|
10
|
-
|
|
11
|
-
export interface LockResult {
|
|
12
|
-
success: boolean;
|
|
13
|
-
lockedBy: string;
|
|
14
|
-
file: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface LockInfo {
|
|
18
|
-
file: string;
|
|
19
|
-
lockedBy: string;
|
|
20
|
-
lockedAt: Date;
|
|
21
|
-
expiresAt: Date;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface LockOptions {
|
|
25
|
-
ttlMs?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
29
|
-
|
|
30
|
-
// ─── Internal Lock Entry ─────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
interface LockEntry {
|
|
33
|
-
file: string;
|
|
34
|
-
lockedBy: string;
|
|
35
|
-
lockedAt: Date;
|
|
36
|
-
expiresAt: Date;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ─── Registry ────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
export class FileLockRegistry {
|
|
42
|
-
private locks = new Map<string, LockEntry>();
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Attempt to lock a file for an agent.
|
|
46
|
-
* Returns success:true if lock acquired, or success:false with current owner.
|
|
47
|
-
* Same agent re-locking is idempotent (refreshes TTL).
|
|
48
|
-
*/
|
|
49
|
-
lock(file: string, agentId: string, options?: LockOptions): LockResult {
|
|
50
|
-
const ttl = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
51
|
-
|
|
52
|
-
// Clean expired locks first
|
|
53
|
-
this.cleanExpiredEntry(file);
|
|
54
|
-
|
|
55
|
-
const existing = this.locks.get(file);
|
|
56
|
-
|
|
57
|
-
if (existing) {
|
|
58
|
-
if (existing.lockedBy === agentId) {
|
|
59
|
-
// Same agent — refresh TTL
|
|
60
|
-
existing.expiresAt = new Date(Date.now() + ttl);
|
|
61
|
-
return { success: true, lockedBy: agentId, file };
|
|
62
|
-
}
|
|
63
|
-
// Different agent holds the lock
|
|
64
|
-
return { success: false, lockedBy: existing.lockedBy, file };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// No lock — acquire
|
|
68
|
-
const now = new Date();
|
|
69
|
-
this.locks.set(file, {
|
|
70
|
-
file,
|
|
71
|
-
lockedBy: agentId,
|
|
72
|
-
lockedAt: now,
|
|
73
|
-
expiresAt: new Date(now.getTime() + ttl),
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
return { success: true, lockedBy: agentId, file };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Release a lock. Only the owner can release.
|
|
81
|
-
* Returns true if released, false if not found or not owner.
|
|
82
|
-
*/
|
|
83
|
-
unlock(file: string, agentId: string): boolean {
|
|
84
|
-
const entry = this.locks.get(file);
|
|
85
|
-
if (!entry) return false;
|
|
86
|
-
if (entry.lockedBy !== agentId) return false;
|
|
87
|
-
|
|
88
|
-
this.locks.delete(file);
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Get lock status for a specific file. Returns null if unlocked.
|
|
94
|
-
*/
|
|
95
|
-
getStatus(file: string): LockInfo | null {
|
|
96
|
-
this.cleanExpiredEntry(file);
|
|
97
|
-
const entry = this.locks.get(file);
|
|
98
|
-
if (!entry) return null;
|
|
99
|
-
return { ...entry };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* List all active locks, optionally filtered by agent.
|
|
104
|
-
*/
|
|
105
|
-
listLocks(agentId?: string): LockInfo[] {
|
|
106
|
-
this.cleanExpired();
|
|
107
|
-
const all = [...this.locks.values()];
|
|
108
|
-
const filtered = agentId ? all.filter(l => l.lockedBy === agentId) : all;
|
|
109
|
-
return filtered.map(l => ({ ...l }));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Release all locks held by a specific agent. Returns count released.
|
|
114
|
-
*/
|
|
115
|
-
releaseAll(agentId: string): number {
|
|
116
|
-
let count = 0;
|
|
117
|
-
for (const [file, entry] of this.locks) {
|
|
118
|
-
if (entry.lockedBy === agentId) {
|
|
119
|
-
this.locks.delete(file);
|
|
120
|
-
count++;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return count;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Remove all expired locks.
|
|
128
|
-
*/
|
|
129
|
-
cleanExpired(): void {
|
|
130
|
-
const now = Date.now();
|
|
131
|
-
for (const [file, entry] of this.locks) {
|
|
132
|
-
if (entry.expiresAt.getTime() <= now) {
|
|
133
|
-
this.locks.delete(file);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Check and remove a single expired lock entry.
|
|
140
|
-
*/
|
|
141
|
-
private cleanExpiredEntry(file: string): void {
|
|
142
|
-
const entry = this.locks.get(file);
|
|
143
|
-
if (entry && entry.expiresAt.getTime() <= Date.now()) {
|
|
144
|
-
this.locks.delete(file);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Serialize state for file persistence */
|
|
149
|
-
serialize(): { locks: Record<string, unknown> } {
|
|
150
|
-
const locks: Record<string, unknown> = {};
|
|
151
|
-
for (const [file, entry] of this.locks) {
|
|
152
|
-
locks[file] = {
|
|
153
|
-
...entry,
|
|
154
|
-
lockedAt: entry.lockedAt.toISOString(),
|
|
155
|
-
expiresAt: entry.expiresAt.toISOString(),
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
return { locks };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Hydrate state from file persistence */
|
|
162
|
-
hydrate(data: { locks?: Record<string, any> }): void {
|
|
163
|
-
this.locks.clear();
|
|
164
|
-
if (!data?.locks) return;
|
|
165
|
-
for (const [file, raw] of Object.entries(data.locks)) {
|
|
166
|
-
this.locks.set(file, {
|
|
167
|
-
...(raw as any),
|
|
168
|
-
lockedAt: new Date((raw as any).lockedAt),
|
|
169
|
-
expiresAt: new Date((raw as any).expiresAt),
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* File Lock Registry — Advisory file locks for multi-agent coordination
|
|
3
|
+
*
|
|
4
|
+
* Prevents conflicting edits when multiple agents work on the same project.
|
|
5
|
+
* Locks are advisory (not enforced at OS level) with TTL auto-release.
|
|
6
|
+
* Default TTL: 10 minutes. Agents see lock status via session_start injection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface LockResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
lockedBy: string;
|
|
14
|
+
file: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LockInfo {
|
|
18
|
+
file: string;
|
|
19
|
+
lockedBy: string;
|
|
20
|
+
lockedAt: Date;
|
|
21
|
+
expiresAt: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LockOptions {
|
|
25
|
+
ttlMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
29
|
+
|
|
30
|
+
// ─── Internal Lock Entry ─────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
interface LockEntry {
|
|
33
|
+
file: string;
|
|
34
|
+
lockedBy: string;
|
|
35
|
+
lockedAt: Date;
|
|
36
|
+
expiresAt: Date;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Registry ────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export class FileLockRegistry {
|
|
42
|
+
private locks = new Map<string, LockEntry>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Attempt to lock a file for an agent.
|
|
46
|
+
* Returns success:true if lock acquired, or success:false with current owner.
|
|
47
|
+
* Same agent re-locking is idempotent (refreshes TTL).
|
|
48
|
+
*/
|
|
49
|
+
lock(file: string, agentId: string, options?: LockOptions): LockResult {
|
|
50
|
+
const ttl = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
51
|
+
|
|
52
|
+
// Clean expired locks first
|
|
53
|
+
this.cleanExpiredEntry(file);
|
|
54
|
+
|
|
55
|
+
const existing = this.locks.get(file);
|
|
56
|
+
|
|
57
|
+
if (existing) {
|
|
58
|
+
if (existing.lockedBy === agentId) {
|
|
59
|
+
// Same agent — refresh TTL
|
|
60
|
+
existing.expiresAt = new Date(Date.now() + ttl);
|
|
61
|
+
return { success: true, lockedBy: agentId, file };
|
|
62
|
+
}
|
|
63
|
+
// Different agent holds the lock
|
|
64
|
+
return { success: false, lockedBy: existing.lockedBy, file };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// No lock — acquire
|
|
68
|
+
const now = new Date();
|
|
69
|
+
this.locks.set(file, {
|
|
70
|
+
file,
|
|
71
|
+
lockedBy: agentId,
|
|
72
|
+
lockedAt: now,
|
|
73
|
+
expiresAt: new Date(now.getTime() + ttl),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return { success: true, lockedBy: agentId, file };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Release a lock. Only the owner can release.
|
|
81
|
+
* Returns true if released, false if not found or not owner.
|
|
82
|
+
*/
|
|
83
|
+
unlock(file: string, agentId: string): boolean {
|
|
84
|
+
const entry = this.locks.get(file);
|
|
85
|
+
if (!entry) return false;
|
|
86
|
+
if (entry.lockedBy !== agentId) return false;
|
|
87
|
+
|
|
88
|
+
this.locks.delete(file);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get lock status for a specific file. Returns null if unlocked.
|
|
94
|
+
*/
|
|
95
|
+
getStatus(file: string): LockInfo | null {
|
|
96
|
+
this.cleanExpiredEntry(file);
|
|
97
|
+
const entry = this.locks.get(file);
|
|
98
|
+
if (!entry) return null;
|
|
99
|
+
return { ...entry };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* List all active locks, optionally filtered by agent.
|
|
104
|
+
*/
|
|
105
|
+
listLocks(agentId?: string): LockInfo[] {
|
|
106
|
+
this.cleanExpired();
|
|
107
|
+
const all = [...this.locks.values()];
|
|
108
|
+
const filtered = agentId ? all.filter(l => l.lockedBy === agentId) : all;
|
|
109
|
+
return filtered.map(l => ({ ...l }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Release all locks held by a specific agent. Returns count released.
|
|
114
|
+
*/
|
|
115
|
+
releaseAll(agentId: string): number {
|
|
116
|
+
let count = 0;
|
|
117
|
+
for (const [file, entry] of this.locks) {
|
|
118
|
+
if (entry.lockedBy === agentId) {
|
|
119
|
+
this.locks.delete(file);
|
|
120
|
+
count++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return count;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Remove all expired locks.
|
|
128
|
+
*/
|
|
129
|
+
cleanExpired(): void {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
for (const [file, entry] of this.locks) {
|
|
132
|
+
if (entry.expiresAt.getTime() <= now) {
|
|
133
|
+
this.locks.delete(file);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check and remove a single expired lock entry.
|
|
140
|
+
*/
|
|
141
|
+
private cleanExpiredEntry(file: string): void {
|
|
142
|
+
const entry = this.locks.get(file);
|
|
143
|
+
if (entry && entry.expiresAt.getTime() <= Date.now()) {
|
|
144
|
+
this.locks.delete(file);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Serialize state for file persistence */
|
|
149
|
+
serialize(): { locks: Record<string, unknown> } {
|
|
150
|
+
const locks: Record<string, unknown> = {};
|
|
151
|
+
for (const [file, entry] of this.locks) {
|
|
152
|
+
locks[file] = {
|
|
153
|
+
...entry,
|
|
154
|
+
lockedAt: entry.lockedAt.toISOString(),
|
|
155
|
+
expiresAt: entry.expiresAt.toISOString(),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return { locks };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Hydrate state from file persistence */
|
|
162
|
+
hydrate(data: { locks?: Record<string, any> }): void {
|
|
163
|
+
this.locks.clear();
|
|
164
|
+
if (!data?.locks) return;
|
|
165
|
+
for (const [file, raw] of Object.entries(data.locks)) {
|
|
166
|
+
this.locks.set(file, {
|
|
167
|
+
...(raw as any),
|
|
168
|
+
lockedAt: new Date((raw as any).lockedAt),
|
|
169
|
+
expiresAt: new Date((raw as any).expiresAt),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|