loki-mode 6.60.0 → 6.62.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +34 -8
- package/autonomy/completion-council.sh +70 -32
- package/autonomy/issue-parser.sh +4 -7
- package/autonomy/loki +238 -119
- package/autonomy/notification-checker.py +49 -23
- package/autonomy/run.sh +162 -79
- package/autonomy/sandbox.sh +91 -24
- package/bin/loki-mode.js +1 -2
- package/bin/postinstall.js +10 -4
- package/dashboard/__init__.py +1 -1
- package/dashboard/control.py +46 -36
- package/dashboard/database.py +21 -4
- package/dashboard/server.py +107 -78
- package/docs/BUG-AUDIT-v6.61.0.md +957 -0
- package/docs/INSTALLATION.md +2 -2
- package/events/bus.py +129 -28
- package/events/bus.ts +41 -27
- package/events/emit.sh +1 -1
- package/integrations/openclaw/README.md +139 -0
- package/integrations/openclaw/SKILL.md +88 -0
- package/integrations/openclaw/bridge/__init__.py +1 -0
- package/integrations/openclaw/bridge/__main__.py +88 -0
- package/integrations/openclaw/bridge/schema_map.py +180 -0
- package/integrations/openclaw/bridge/watcher.py +100 -0
- package/integrations/openclaw/scripts/format-progress.sh +80 -0
- package/integrations/openclaw/scripts/poll-status.sh +74 -0
- package/integrations/vibe-kanban.md +289 -0
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +96 -73
- package/memory/consolidation.py +21 -6
- package/memory/engine.py +53 -26
- package/memory/layers/index_layer.py +16 -3
- package/memory/layers/timeline_layer.py +16 -3
- package/memory/retrieval.py +4 -1
- package/memory/schemas.py +4 -2
- package/memory/storage.py +25 -4
- package/memory/token_economics.py +9 -2
- package/memory/vector_index.py +2 -2
- package/package.json +3 -1
- package/providers/cline.sh +5 -4
- package/providers/codex.sh +27 -5
- package/providers/gemini.sh +59 -23
- package/providers/loader.sh +3 -2
- package/skills/parallel-workflows.md +9 -7
- package/state/__init__.py +10 -0
- package/state/index.ts +18 -0
- package/state/manager.py +1801 -0
- package/state/manager.ts +1774 -0
- package/state/sqlite_backend.py +188 -0
- package/state/test_manager.py +703 -0
- package/state/test_manager.ts +366 -0
- package/templates/README.md +19 -4
- package/templates/dashboard.md +45 -0
- package/templates/data-pipeline.md +45 -0
- package/templates/game.md +48 -0
- package/templates/microservice.md +49 -0
- package/templates/npm-library.md +42 -0
- package/templates/rest-api.md +170 -33
- package/templates/slack-bot.md +48 -0
- package/templates/web-scraper.md +45 -0
- package/web-app/server.py +360 -191
- package/templates/saas-app.md +0 -42
package/state/manager.ts
ADDED
|
@@ -0,0 +1,1774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized State Manager for Loki Mode - TypeScript Implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides unified state management with:
|
|
5
|
+
* - File-based caching with chokidar for change detection
|
|
6
|
+
* - Thread-safe operations with file locking
|
|
7
|
+
* - Event bus integration for broadcasting changes
|
|
8
|
+
* - Subscription system for reactive updates
|
|
9
|
+
* - Version history with rollback capability (SYN-015)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { EventEmitter } from "events";
|
|
15
|
+
|
|
16
|
+
// Try to import chokidar for file watching
|
|
17
|
+
let chokidar: typeof import("chokidar") | null = null;
|
|
18
|
+
try {
|
|
19
|
+
chokidar = require("chokidar");
|
|
20
|
+
} catch {
|
|
21
|
+
// chokidar not available
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Try to import event bus
|
|
25
|
+
let EventBus: typeof import("../events/bus").EventBus | null = null;
|
|
26
|
+
try {
|
|
27
|
+
const eventBusModule = require("../events/bus");
|
|
28
|
+
EventBus = eventBusModule.EventBus;
|
|
29
|
+
} catch {
|
|
30
|
+
// Event bus not available
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Managed state files
|
|
35
|
+
*/
|
|
36
|
+
export const ManagedFile = {
|
|
37
|
+
ORCHESTRATOR: "state/orchestrator.json",
|
|
38
|
+
AUTONOMY: "autonomy-state.json",
|
|
39
|
+
QUEUE_PENDING: "queue/pending.json",
|
|
40
|
+
QUEUE_IN_PROGRESS: "queue/in-progress.json",
|
|
41
|
+
QUEUE_COMPLETED: "queue/completed.json",
|
|
42
|
+
QUEUE_FAILED: "queue/failed.json",
|
|
43
|
+
QUEUE_CURRENT: "queue/current-task.json",
|
|
44
|
+
MEMORY_INDEX: "memory/index.json",
|
|
45
|
+
MEMORY_TIMELINE: "memory/timeline.json",
|
|
46
|
+
DASHBOARD: "dashboard-state.json",
|
|
47
|
+
AGENTS: "state/agents.json",
|
|
48
|
+
RESOURCES: "state/resources.json",
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
export type ManagedFileType = (typeof ManagedFile)[keyof typeof ManagedFile];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* State change event
|
|
55
|
+
*/
|
|
56
|
+
export interface StateChange {
|
|
57
|
+
filePath: string;
|
|
58
|
+
oldValue: Record<string, unknown> | null;
|
|
59
|
+
newValue: Record<string, unknown>;
|
|
60
|
+
timestamp: string;
|
|
61
|
+
changeType: "create" | "update" | "delete";
|
|
62
|
+
source: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* State change callback type
|
|
67
|
+
*/
|
|
68
|
+
export type StateCallback = (change: StateChange) => void;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Disposable subscription
|
|
72
|
+
*/
|
|
73
|
+
export interface Disposable {
|
|
74
|
+
dispose(): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Cache entry type
|
|
79
|
+
*/
|
|
80
|
+
interface CacheEntry {
|
|
81
|
+
data: Record<string, unknown>;
|
|
82
|
+
hash: string;
|
|
83
|
+
mtime: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Default version retention limit (SYN-015)
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_VERSION_RETENTION = 10;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* State version data (SYN-015)
|
|
93
|
+
*/
|
|
94
|
+
export interface StateVersion {
|
|
95
|
+
version: number;
|
|
96
|
+
timestamp: string;
|
|
97
|
+
data: Record<string, unknown>;
|
|
98
|
+
source: string;
|
|
99
|
+
changeType: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Version info summary (without full data) (SYN-015)
|
|
104
|
+
*/
|
|
105
|
+
export interface VersionInfo {
|
|
106
|
+
version: number;
|
|
107
|
+
timestamp: string;
|
|
108
|
+
source: string;
|
|
109
|
+
changeType: string;
|
|
110
|
+
dataHash: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Subscription filter for selective notifications (SYN-016)
|
|
115
|
+
*/
|
|
116
|
+
export interface SubscriptionFilter {
|
|
117
|
+
files?: (string | ManagedFileType)[];
|
|
118
|
+
changeTypes?: ("create" | "update" | "delete")[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Notification channel interface (SYN-016)
|
|
123
|
+
*/
|
|
124
|
+
export interface NotificationChannel {
|
|
125
|
+
notify(change: StateChange): void;
|
|
126
|
+
close(): void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* File-based notification channel for CLI/scripts
|
|
131
|
+
*/
|
|
132
|
+
export class FileNotificationChannel implements NotificationChannel {
|
|
133
|
+
private notificationFile: string;
|
|
134
|
+
|
|
135
|
+
constructor(notificationFile: string) {
|
|
136
|
+
this.notificationFile = notificationFile;
|
|
137
|
+
const dir = path.dirname(notificationFile);
|
|
138
|
+
if (!fs.existsSync(dir)) {
|
|
139
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
notify(change: StateChange): void {
|
|
144
|
+
try {
|
|
145
|
+
const notification = {
|
|
146
|
+
timestamp: change.timestamp,
|
|
147
|
+
filePath: change.filePath,
|
|
148
|
+
changeType: change.changeType,
|
|
149
|
+
source: change.source,
|
|
150
|
+
diff: getStateDiff(change.oldValue, change.newValue),
|
|
151
|
+
};
|
|
152
|
+
fs.appendFileSync(this.notificationFile, JSON.stringify(notification) + "\n");
|
|
153
|
+
} catch {
|
|
154
|
+
// File notification errors shouldn't break state management
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
close(): void {
|
|
159
|
+
// No cleanup needed for file channel
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* In-memory notification channel for testing
|
|
165
|
+
*/
|
|
166
|
+
export class InMemoryNotificationChannel implements NotificationChannel {
|
|
167
|
+
public notifications: Array<{
|
|
168
|
+
timestamp: string;
|
|
169
|
+
filePath: string;
|
|
170
|
+
changeType: string;
|
|
171
|
+
source: string;
|
|
172
|
+
oldValue: Record<string, unknown> | null;
|
|
173
|
+
newValue: Record<string, unknown>;
|
|
174
|
+
diff: ReturnType<typeof getStateDiff>;
|
|
175
|
+
}> = [];
|
|
176
|
+
private maxSize: number;
|
|
177
|
+
|
|
178
|
+
constructor(maxSize: number = 1000) {
|
|
179
|
+
this.maxSize = maxSize;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
notify(change: StateChange): void {
|
|
183
|
+
const notification = {
|
|
184
|
+
timestamp: change.timestamp,
|
|
185
|
+
filePath: change.filePath,
|
|
186
|
+
changeType: change.changeType,
|
|
187
|
+
source: change.source,
|
|
188
|
+
oldValue: change.oldValue,
|
|
189
|
+
newValue: change.newValue,
|
|
190
|
+
diff: getStateDiff(change.oldValue, change.newValue),
|
|
191
|
+
};
|
|
192
|
+
this.notifications.push(notification);
|
|
193
|
+
if (this.notifications.length > this.maxSize) {
|
|
194
|
+
this.notifications = this.notifications.slice(-this.maxSize);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
getNotifications(): typeof this.notifications {
|
|
199
|
+
return [...this.notifications];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
clear(): void {
|
|
203
|
+
this.notifications = [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
close(): void {
|
|
207
|
+
this.notifications = [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Filtered subscriber entry
|
|
213
|
+
*/
|
|
214
|
+
interface FilteredSubscriber {
|
|
215
|
+
callback: StateCallback;
|
|
216
|
+
filter: SubscriptionFilter;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// -------------------------------------------------------------------------
|
|
220
|
+
// Optimistic Updates Types (SYN-014)
|
|
221
|
+
// -------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Conflict resolution strategies for optimistic updates
|
|
225
|
+
*/
|
|
226
|
+
export enum ConflictStrategy {
|
|
227
|
+
LAST_WRITE_WINS = "last_write_wins", // Default: latest write overwrites
|
|
228
|
+
MERGE = "merge", // Merge compatible changes
|
|
229
|
+
REJECT = "reject", // Reject and notify caller
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Version vector for tracking state versions per source
|
|
234
|
+
*/
|
|
235
|
+
export class VersionVector {
|
|
236
|
+
private versions: Map<string, number>;
|
|
237
|
+
|
|
238
|
+
constructor(data?: Record<string, number>) {
|
|
239
|
+
this.versions = new Map(Object.entries(data || {}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Increment version for a source
|
|
244
|
+
*/
|
|
245
|
+
increment(source: string): void {
|
|
246
|
+
this.versions.set(source, (this.versions.get(source) || 0) + 1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get version for a source
|
|
251
|
+
*/
|
|
252
|
+
get(source: string): number {
|
|
253
|
+
return this.versions.get(source) || 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Merge two version vectors (take max of each)
|
|
258
|
+
*/
|
|
259
|
+
merge(other: VersionVector): VersionVector {
|
|
260
|
+
const merged = new VersionVector();
|
|
261
|
+
const allSources = new Set([...this.versions.keys(), ...other.versions.keys()]);
|
|
262
|
+
for (const source of allSources) {
|
|
263
|
+
merged.versions.set(source, Math.max(this.get(source), other.get(source)));
|
|
264
|
+
}
|
|
265
|
+
return merged;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Check if this vector dominates (is causally after) another
|
|
270
|
+
*/
|
|
271
|
+
dominates(other: VersionVector): boolean {
|
|
272
|
+
// Check if any source in other has a greater version
|
|
273
|
+
for (const [source, version] of other.versions) {
|
|
274
|
+
if (this.get(source) < version) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Must have at least one greater version
|
|
279
|
+
for (const [source, version] of this.versions) {
|
|
280
|
+
if (version > other.get(source)) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Check if two vectors are concurrent (neither dominates)
|
|
289
|
+
*/
|
|
290
|
+
concurrentWith(other: VersionVector): boolean {
|
|
291
|
+
return !this.dominates(other) && !other.dominates(this);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Convert to plain object
|
|
296
|
+
*/
|
|
297
|
+
toDict(): Record<string, number> {
|
|
298
|
+
return Object.fromEntries(this.versions);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Create from plain object
|
|
303
|
+
*/
|
|
304
|
+
static fromDict(data: Record<string, number>): VersionVector {
|
|
305
|
+
return new VersionVector(data);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Pending optimistic update
|
|
311
|
+
*/
|
|
312
|
+
export interface PendingUpdate {
|
|
313
|
+
id: string;
|
|
314
|
+
key: string;
|
|
315
|
+
value: unknown;
|
|
316
|
+
source: string;
|
|
317
|
+
timestamp: string;
|
|
318
|
+
versionVector: VersionVector;
|
|
319
|
+
status: "pending" | "committed" | "rejected";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Information about a detected conflict
|
|
324
|
+
*/
|
|
325
|
+
export interface ConflictInfo {
|
|
326
|
+
key: string;
|
|
327
|
+
localValue: unknown;
|
|
328
|
+
remoteValue: unknown;
|
|
329
|
+
localSource: string;
|
|
330
|
+
remoteSource: string;
|
|
331
|
+
localVersion: VersionVector;
|
|
332
|
+
remoteVersion: VersionVector;
|
|
333
|
+
resolution?: string;
|
|
334
|
+
resolvedValue?: unknown;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Compute MD5-like hash of data
|
|
339
|
+
*/
|
|
340
|
+
function computeHash(data: Record<string, unknown>): string {
|
|
341
|
+
const content = JSON.stringify(data, Object.keys(data).sort());
|
|
342
|
+
let hash = 0;
|
|
343
|
+
for (let i = 0; i < content.length; i++) {
|
|
344
|
+
const char = content.charCodeAt(i);
|
|
345
|
+
hash = ((hash << 5) - hash) + char;
|
|
346
|
+
hash = hash & hash;
|
|
347
|
+
}
|
|
348
|
+
return hash.toString(16);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get diff between old and new values
|
|
353
|
+
*/
|
|
354
|
+
export function getStateDiff(
|
|
355
|
+
oldValue: Record<string, unknown> | null,
|
|
356
|
+
newValue: Record<string, unknown>
|
|
357
|
+
): { added: Record<string, unknown>; removed: Record<string, unknown>; changed: Record<string, unknown> } {
|
|
358
|
+
if (oldValue === null) {
|
|
359
|
+
return { added: newValue, removed: {}, changed: {} };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const diff: { added: Record<string, unknown>; removed: Record<string, unknown>; changed: Record<string, unknown> } = {
|
|
363
|
+
added: {},
|
|
364
|
+
removed: {},
|
|
365
|
+
changed: {},
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const oldKeys = new Set(Object.keys(oldValue));
|
|
369
|
+
const newKeys = new Set(Object.keys(newValue));
|
|
370
|
+
|
|
371
|
+
// Added keys
|
|
372
|
+
for (const key of newKeys) {
|
|
373
|
+
if (!oldKeys.has(key)) {
|
|
374
|
+
diff.added[key] = newValue[key];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Removed keys
|
|
379
|
+
for (const key of oldKeys) {
|
|
380
|
+
if (!newKeys.has(key)) {
|
|
381
|
+
diff.removed[key] = oldValue[key];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Changed keys
|
|
386
|
+
for (const key of oldKeys) {
|
|
387
|
+
if (newKeys.has(key) && JSON.stringify(oldValue[key]) !== JSON.stringify(newValue[key])) {
|
|
388
|
+
diff.changed[key] = {
|
|
389
|
+
old: oldValue[key],
|
|
390
|
+
new: newValue[key],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return diff;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Centralized State Manager
|
|
400
|
+
*/
|
|
401
|
+
export class StateManager extends EventEmitter {
|
|
402
|
+
private lokiDir: string;
|
|
403
|
+
private cache: Map<string, CacheEntry>;
|
|
404
|
+
private subscribers: Set<StateCallback>;
|
|
405
|
+
private filteredSubscribers: FilteredSubscriber[];
|
|
406
|
+
private notificationChannels: NotificationChannel[];
|
|
407
|
+
private watcher: ReturnType<typeof import("chokidar").watch> | null;
|
|
408
|
+
private eventBus: InstanceType<typeof import("../events/bus.ts").EventBus> | null;
|
|
409
|
+
private enableWatch: boolean;
|
|
410
|
+
private enableEvents: boolean;
|
|
411
|
+
|
|
412
|
+
// Optimistic updates tracking (SYN-014)
|
|
413
|
+
private pendingUpdates: Map<string, PendingUpdate[]>;
|
|
414
|
+
private versionVectors: Map<string, VersionVector>;
|
|
415
|
+
private conflictStrategy: ConflictStrategy;
|
|
416
|
+
|
|
417
|
+
// State versioning (SYN-015)
|
|
418
|
+
private enableVersioning: boolean;
|
|
419
|
+
private versionRetention: number;
|
|
420
|
+
private versionCounters: Map<string, number>;
|
|
421
|
+
|
|
422
|
+
constructor(options: {
|
|
423
|
+
lokiDir?: string;
|
|
424
|
+
enableWatch?: boolean;
|
|
425
|
+
enableEvents?: boolean;
|
|
426
|
+
enableVersioning?: boolean;
|
|
427
|
+
versionRetention?: number;
|
|
428
|
+
} = {}) {
|
|
429
|
+
super();
|
|
430
|
+
|
|
431
|
+
this.lokiDir = options.lokiDir || ".loki";
|
|
432
|
+
this.enableWatch = options.enableWatch !== false && chokidar !== null;
|
|
433
|
+
this.enableEvents = options.enableEvents !== false && EventBus !== null;
|
|
434
|
+
this.enableVersioning = options.enableVersioning !== false;
|
|
435
|
+
this.versionRetention = options.versionRetention ?? DEFAULT_VERSION_RETENTION;
|
|
436
|
+
this.cache = new Map();
|
|
437
|
+
this.subscribers = new Set();
|
|
438
|
+
this.filteredSubscribers = [];
|
|
439
|
+
this.notificationChannels = [];
|
|
440
|
+
this.watcher = null;
|
|
441
|
+
this.eventBus = null;
|
|
442
|
+
|
|
443
|
+
// Initialize optimistic update tracking (SYN-014)
|
|
444
|
+
this.pendingUpdates = new Map();
|
|
445
|
+
this.versionVectors = new Map();
|
|
446
|
+
this.conflictStrategy = ConflictStrategy.LAST_WRITE_WINS;
|
|
447
|
+
|
|
448
|
+
// Initialize version counters (SYN-015)
|
|
449
|
+
this.versionCounters = new Map();
|
|
450
|
+
|
|
451
|
+
// Ensure directories exist
|
|
452
|
+
this.ensureDirectories();
|
|
453
|
+
|
|
454
|
+
// Start file watching
|
|
455
|
+
if (this.enableWatch) {
|
|
456
|
+
this.startWatching();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Initialize event bus
|
|
460
|
+
if (this.enableEvents && EventBus) {
|
|
461
|
+
this.eventBus = new EventBus(this.lokiDir);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Ensure all required directories exist
|
|
467
|
+
*/
|
|
468
|
+
private ensureDirectories(): void {
|
|
469
|
+
const directories = [
|
|
470
|
+
this.lokiDir,
|
|
471
|
+
path.join(this.lokiDir, "state"),
|
|
472
|
+
path.join(this.lokiDir, "state", "history"), // Version history (SYN-015)
|
|
473
|
+
path.join(this.lokiDir, "queue"),
|
|
474
|
+
path.join(this.lokiDir, "memory"),
|
|
475
|
+
path.join(this.lokiDir, "events"),
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
for (const dir of directories) {
|
|
479
|
+
if (!fs.existsSync(dir)) {
|
|
480
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Start file system watcher
|
|
487
|
+
*/
|
|
488
|
+
private startWatching(): void {
|
|
489
|
+
if (!chokidar) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
this.watcher = chokidar.watch(this.lokiDir, {
|
|
494
|
+
persistent: true,
|
|
495
|
+
ignoreInitial: true,
|
|
496
|
+
ignored: [
|
|
497
|
+
/(^|[/\\])\../, // dot files
|
|
498
|
+
/\.lock$/, // lock files
|
|
499
|
+
/\.tmp_/, // temp files
|
|
500
|
+
],
|
|
501
|
+
depth: 3,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
this.watcher.on("change", (filePath: string) => {
|
|
505
|
+
this.onFileChanged(filePath);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
this.watcher.on("add", (filePath: string) => {
|
|
509
|
+
this.onFileChanged(filePath);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Stop the state manager
|
|
515
|
+
*/
|
|
516
|
+
stop(): void {
|
|
517
|
+
if (this.watcher) {
|
|
518
|
+
this.watcher.close();
|
|
519
|
+
this.watcher = null;
|
|
520
|
+
}
|
|
521
|
+
this.cache.clear();
|
|
522
|
+
this.subscribers.clear();
|
|
523
|
+
this.filteredSubscribers = [];
|
|
524
|
+
|
|
525
|
+
// Close all notification channels
|
|
526
|
+
for (const channel of this.notificationChannels) {
|
|
527
|
+
try {
|
|
528
|
+
channel.close();
|
|
529
|
+
} catch {
|
|
530
|
+
// Ignore close errors
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
this.notificationChannels = [];
|
|
534
|
+
|
|
535
|
+
this.removeAllListeners();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Resolve file reference to absolute path
|
|
540
|
+
*/
|
|
541
|
+
private resolvePath(fileRef: string | ManagedFileType): string {
|
|
542
|
+
return path.join(this.lokiDir, fileRef);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Read JSON file
|
|
547
|
+
*/
|
|
548
|
+
private readFile(filePath: string): Record<string, unknown> | null {
|
|
549
|
+
try {
|
|
550
|
+
if (!fs.existsSync(filePath)) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
554
|
+
return JSON.parse(content);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
// Log error for debugging (corrupted JSON, empty files, etc.)
|
|
557
|
+
console.error(`[StateManager] Error reading file ${filePath}:`, err instanceof Error ? err.message : String(err));
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Write JSON file atomically
|
|
564
|
+
*/
|
|
565
|
+
private writeFile(filePath: string, data: Record<string, unknown>): void {
|
|
566
|
+
const dir = path.dirname(filePath);
|
|
567
|
+
if (!fs.existsSync(dir)) {
|
|
568
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Write to temp file first
|
|
572
|
+
const tempPath = path.join(dir, `.tmp_${Date.now()}_${Math.random().toString(36).slice(2)}.json`);
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
|
|
576
|
+
// Atomic rename
|
|
577
|
+
fs.renameSync(tempPath, filePath);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
// Clean up temp file on error
|
|
580
|
+
if (fs.existsSync(tempPath)) {
|
|
581
|
+
fs.unlinkSync(tempPath);
|
|
582
|
+
}
|
|
583
|
+
throw err;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Get from cache if valid
|
|
589
|
+
*/
|
|
590
|
+
private getFromCache(filePath: string): Record<string, unknown> | null {
|
|
591
|
+
const entry = this.cache.get(filePath);
|
|
592
|
+
if (!entry) {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Check if file still exists and mtime matches
|
|
597
|
+
try {
|
|
598
|
+
const stats = fs.statSync(filePath);
|
|
599
|
+
if (stats.mtimeMs === entry.mtime) {
|
|
600
|
+
return entry.data;
|
|
601
|
+
}
|
|
602
|
+
} catch {
|
|
603
|
+
// File doesn't exist
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Put in cache
|
|
611
|
+
*/
|
|
612
|
+
private putInCache(filePath: string, data: Record<string, unknown>): void {
|
|
613
|
+
try {
|
|
614
|
+
const stats = fs.statSync(filePath);
|
|
615
|
+
this.cache.set(filePath, {
|
|
616
|
+
data,
|
|
617
|
+
hash: computeHash(data),
|
|
618
|
+
mtime: stats.mtimeMs,
|
|
619
|
+
});
|
|
620
|
+
} catch {
|
|
621
|
+
// Can't cache without stats
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Invalidate cache entry
|
|
627
|
+
*/
|
|
628
|
+
private invalidateCache(filePath: string): void {
|
|
629
|
+
this.cache.delete(filePath);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Get state from a managed file
|
|
634
|
+
*/
|
|
635
|
+
getState(
|
|
636
|
+
fileRef: string | ManagedFileType,
|
|
637
|
+
defaultValue: Record<string, unknown> | null = null
|
|
638
|
+
): Record<string, unknown> | null {
|
|
639
|
+
const filePath = this.resolvePath(fileRef);
|
|
640
|
+
|
|
641
|
+
// Try cache first
|
|
642
|
+
const cached = this.getFromCache(filePath);
|
|
643
|
+
if (cached !== null) {
|
|
644
|
+
return cached;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Read from file
|
|
648
|
+
const data = this.readFile(filePath);
|
|
649
|
+
if (data === null) {
|
|
650
|
+
return defaultValue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Update cache
|
|
654
|
+
this.putInCache(filePath, data);
|
|
655
|
+
|
|
656
|
+
return data;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Set state in a managed file
|
|
661
|
+
*/
|
|
662
|
+
setState(
|
|
663
|
+
fileRef: string | ManagedFileType,
|
|
664
|
+
data: Record<string, unknown>,
|
|
665
|
+
source: string = "state-manager",
|
|
666
|
+
saveVersion: boolean = true
|
|
667
|
+
): StateChange {
|
|
668
|
+
const filePath = this.resolvePath(fileRef);
|
|
669
|
+
const relPath = typeof fileRef === "string" ? fileRef : fileRef;
|
|
670
|
+
|
|
671
|
+
// Get old value for change tracking
|
|
672
|
+
const oldValue = this.getState(fileRef);
|
|
673
|
+
|
|
674
|
+
// Determine change type
|
|
675
|
+
const changeType = oldValue === null ? "create" : "update";
|
|
676
|
+
|
|
677
|
+
// Save version before writing new data (SYN-015)
|
|
678
|
+
if (this.enableVersioning && saveVersion && oldValue !== null) {
|
|
679
|
+
this.saveVersion(fileRef, oldValue, source, changeType);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Write to file
|
|
683
|
+
this.writeFile(filePath, data);
|
|
684
|
+
|
|
685
|
+
// Update cache
|
|
686
|
+
this.putInCache(filePath, data);
|
|
687
|
+
|
|
688
|
+
// Create change object
|
|
689
|
+
const change: StateChange = {
|
|
690
|
+
filePath: relPath,
|
|
691
|
+
oldValue,
|
|
692
|
+
newValue: data,
|
|
693
|
+
timestamp: new Date().toISOString(),
|
|
694
|
+
changeType,
|
|
695
|
+
source,
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
// Broadcast change
|
|
699
|
+
this.broadcast(change);
|
|
700
|
+
|
|
701
|
+
return change;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Merge updates into existing state
|
|
706
|
+
*/
|
|
707
|
+
updateState(
|
|
708
|
+
fileRef: string | ManagedFileType,
|
|
709
|
+
updates: Record<string, unknown>,
|
|
710
|
+
source: string = "state-manager"
|
|
711
|
+
): StateChange {
|
|
712
|
+
const current = this.getState(fileRef, {});
|
|
713
|
+
const merged = { ...current, ...updates };
|
|
714
|
+
return this.setState(fileRef, merged, source);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Acquire a simple file lock using a lock file
|
|
719
|
+
*/
|
|
720
|
+
private acquireLock(filePath: string): { release: () => void } {
|
|
721
|
+
const lockPath = `${filePath}.lock`;
|
|
722
|
+
const lockDir = path.dirname(lockPath);
|
|
723
|
+
|
|
724
|
+
if (!fs.existsSync(lockDir)) {
|
|
725
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Simple lock implementation using exclusive file creation
|
|
729
|
+
let fd: number;
|
|
730
|
+
const maxRetries = 50;
|
|
731
|
+
const retryDelayMs = 100;
|
|
732
|
+
|
|
733
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
734
|
+
try {
|
|
735
|
+
fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
|
736
|
+
fs.writeSync(fd, String(process.pid));
|
|
737
|
+
fs.closeSync(fd);
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
release: () => {
|
|
741
|
+
try {
|
|
742
|
+
fs.unlinkSync(lockPath);
|
|
743
|
+
} catch {
|
|
744
|
+
// Lock file may already be removed
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
} catch {
|
|
749
|
+
// Lock exists, wait and retry
|
|
750
|
+
if (i < maxRetries - 1) {
|
|
751
|
+
const start = Date.now();
|
|
752
|
+
while (Date.now() - start < retryDelayMs) {
|
|
753
|
+
// Busy wait for a short time
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Timeout - force acquire lock by removing stale lock
|
|
760
|
+
try {
|
|
761
|
+
fs.unlinkSync(lockPath);
|
|
762
|
+
} catch {
|
|
763
|
+
// Ignore
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
|
767
|
+
fs.writeSync(fd, String(process.pid));
|
|
768
|
+
fs.closeSync(fd);
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
release: () => {
|
|
772
|
+
try {
|
|
773
|
+
fs.unlinkSync(lockPath);
|
|
774
|
+
} catch {
|
|
775
|
+
// Lock file may already be removed
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Delete a managed state file
|
|
783
|
+
*/
|
|
784
|
+
deleteState(
|
|
785
|
+
fileRef: string | ManagedFileType,
|
|
786
|
+
source: string = "state-manager"
|
|
787
|
+
): StateChange | null {
|
|
788
|
+
const filePath = this.resolvePath(fileRef);
|
|
789
|
+
const relPath = typeof fileRef === "string" ? fileRef : fileRef;
|
|
790
|
+
|
|
791
|
+
if (!fs.existsSync(filePath)) {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const oldValue = this.getState(fileRef);
|
|
796
|
+
|
|
797
|
+
// Acquire lock before deletion
|
|
798
|
+
const lock = this.acquireLock(filePath);
|
|
799
|
+
try {
|
|
800
|
+
fs.unlinkSync(filePath);
|
|
801
|
+
} finally {
|
|
802
|
+
lock.release();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Invalidate cache
|
|
806
|
+
this.invalidateCache(filePath);
|
|
807
|
+
|
|
808
|
+
// Create change object
|
|
809
|
+
const change: StateChange = {
|
|
810
|
+
filePath: relPath,
|
|
811
|
+
oldValue,
|
|
812
|
+
newValue: {},
|
|
813
|
+
timestamp: new Date().toISOString(),
|
|
814
|
+
changeType: "delete",
|
|
815
|
+
source,
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// Broadcast change
|
|
819
|
+
this.broadcast(change);
|
|
820
|
+
|
|
821
|
+
return change;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Subscribe to state changes with optional filtering
|
|
826
|
+
*/
|
|
827
|
+
subscribe(
|
|
828
|
+
callback: StateCallback,
|
|
829
|
+
fileFilter?: (string | ManagedFileType)[],
|
|
830
|
+
changeTypes?: ("create" | "update" | "delete")[]
|
|
831
|
+
): Disposable {
|
|
832
|
+
// If any filter is specified, use filtered subscribers
|
|
833
|
+
if (fileFilter || changeTypes) {
|
|
834
|
+
const filter: SubscriptionFilter = {
|
|
835
|
+
files: fileFilter,
|
|
836
|
+
changeTypes: changeTypes,
|
|
837
|
+
};
|
|
838
|
+
this.filteredSubscribers.push({ callback, filter });
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
dispose: () => {
|
|
842
|
+
this.filteredSubscribers = this.filteredSubscribers.filter(
|
|
843
|
+
(sub) => sub.callback !== callback
|
|
844
|
+
);
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// No filter, use simple subscriber set
|
|
850
|
+
this.subscribers.add(callback);
|
|
851
|
+
|
|
852
|
+
return {
|
|
853
|
+
dispose: () => {
|
|
854
|
+
this.subscribers.delete(callback);
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Subscribe with a SubscriptionFilter object
|
|
861
|
+
*/
|
|
862
|
+
subscribeFiltered(callback: StateCallback, filter: SubscriptionFilter): Disposable {
|
|
863
|
+
this.filteredSubscribers.push({ callback, filter });
|
|
864
|
+
|
|
865
|
+
return {
|
|
866
|
+
dispose: () => {
|
|
867
|
+
this.filteredSubscribers = this.filteredSubscribers.filter(
|
|
868
|
+
(sub) => sub.callback !== callback
|
|
869
|
+
);
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Add a notification channel for state changes
|
|
876
|
+
*/
|
|
877
|
+
addNotificationChannel(channel: NotificationChannel): Disposable {
|
|
878
|
+
this.notificationChannels.push(channel);
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
dispose: () => {
|
|
882
|
+
const index = this.notificationChannels.indexOf(channel);
|
|
883
|
+
if (index >= 0) {
|
|
884
|
+
this.notificationChannels.splice(index, 1);
|
|
885
|
+
channel.close();
|
|
886
|
+
}
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Check if a change matches a subscription filter
|
|
893
|
+
*/
|
|
894
|
+
private matchesFilter(change: StateChange, filter: SubscriptionFilter): boolean {
|
|
895
|
+
// Check file filter
|
|
896
|
+
if (filter.files && filter.files.length > 0) {
|
|
897
|
+
const filterPaths = new Set(filter.files);
|
|
898
|
+
if (!filterPaths.has(change.filePath)) {
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Check change type filter
|
|
904
|
+
if (filter.changeTypes && filter.changeTypes.length > 0) {
|
|
905
|
+
if (!filter.changeTypes.includes(change.changeType)) {
|
|
906
|
+
return false;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Notify all internal subscribers
|
|
915
|
+
*/
|
|
916
|
+
private notifySubscribers(change: StateChange): void {
|
|
917
|
+
// Notify simple subscribers (no filter)
|
|
918
|
+
for (const callback of this.subscribers) {
|
|
919
|
+
try {
|
|
920
|
+
callback(change);
|
|
921
|
+
} catch {
|
|
922
|
+
// Don't let one callback break others
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Notify filtered subscribers
|
|
927
|
+
for (const { callback, filter } of this.filteredSubscribers) {
|
|
928
|
+
try {
|
|
929
|
+
if (this.matchesFilter(change, filter)) {
|
|
930
|
+
callback(change);
|
|
931
|
+
}
|
|
932
|
+
} catch {
|
|
933
|
+
// Don't let one callback break others
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Emit state change event to the event bus
|
|
940
|
+
*/
|
|
941
|
+
private emitStateEvent(change: StateChange): void {
|
|
942
|
+
if (!this.eventBus || !this.enableEvents) {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
this.eventBus.emitSimple("state", "runner", "state_changed", {
|
|
948
|
+
filePath: change.filePath,
|
|
949
|
+
changeType: change.changeType,
|
|
950
|
+
sourceComponent: change.source,
|
|
951
|
+
timestamp: change.timestamp,
|
|
952
|
+
diff: getStateDiff(change.oldValue, change.newValue),
|
|
953
|
+
});
|
|
954
|
+
} catch {
|
|
955
|
+
// Event bus errors shouldn't break state management
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Notify all notification channels
|
|
961
|
+
*/
|
|
962
|
+
private notifyChannels(change: StateChange): void {
|
|
963
|
+
for (const channel of this.notificationChannels) {
|
|
964
|
+
try {
|
|
965
|
+
channel.notify(change);
|
|
966
|
+
} catch {
|
|
967
|
+
// Channel errors shouldn't break state management
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Broadcast a state change to all subscribers and channels
|
|
974
|
+
*
|
|
975
|
+
* This is the main notification method that:
|
|
976
|
+
* 1. Notifies internal callback subscribers
|
|
977
|
+
* 2. Emits events to the event bus
|
|
978
|
+
* 3. Emits "change" event (EventEmitter)
|
|
979
|
+
* 4. Sends notifications to all registered channels
|
|
980
|
+
*/
|
|
981
|
+
private broadcast(change: StateChange): void {
|
|
982
|
+
// 1. Notify internal subscribers
|
|
983
|
+
this.notifySubscribers(change);
|
|
984
|
+
|
|
985
|
+
// 2. Emit to event bus
|
|
986
|
+
this.emitStateEvent(change);
|
|
987
|
+
|
|
988
|
+
// 3. Emit EventEmitter event
|
|
989
|
+
this.emit("change", change);
|
|
990
|
+
|
|
991
|
+
// 4. Notify notification channels
|
|
992
|
+
this.notifyChannels(change);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Handle file change detected by watcher
|
|
997
|
+
*/
|
|
998
|
+
private onFileChanged(filePath: string): void {
|
|
999
|
+
// Only handle JSON files
|
|
1000
|
+
if (!filePath.endsWith(".json")) {
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Ignore lock files and temp files
|
|
1005
|
+
if (filePath.includes(".lock") || filePath.includes(".tmp_")) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Invalidate cache
|
|
1010
|
+
this.invalidateCache(filePath);
|
|
1011
|
+
|
|
1012
|
+
// Read new value
|
|
1013
|
+
let newValue: Record<string, unknown> | null;
|
|
1014
|
+
try {
|
|
1015
|
+
newValue = this.readFile(filePath);
|
|
1016
|
+
} catch {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (newValue === null) {
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Get old value from cache (if available)
|
|
1025
|
+
const oldEntry = this.cache.get(filePath);
|
|
1026
|
+
const oldValue = oldEntry ? oldEntry.data : null;
|
|
1027
|
+
|
|
1028
|
+
// Update cache
|
|
1029
|
+
this.putInCache(filePath, newValue);
|
|
1030
|
+
|
|
1031
|
+
// Create relative path
|
|
1032
|
+
let relPath: string;
|
|
1033
|
+
try {
|
|
1034
|
+
relPath = path.relative(this.lokiDir, filePath);
|
|
1035
|
+
} catch {
|
|
1036
|
+
relPath = filePath;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Create and broadcast change
|
|
1040
|
+
const change: StateChange = {
|
|
1041
|
+
filePath: relPath,
|
|
1042
|
+
oldValue,
|
|
1043
|
+
newValue,
|
|
1044
|
+
timestamp: new Date().toISOString(),
|
|
1045
|
+
changeType: "update",
|
|
1046
|
+
source: "external",
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
this.broadcast(change);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// -------------------------------------------------------------------------
|
|
1053
|
+
// Convenience Methods
|
|
1054
|
+
// -------------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Get orchestrator state
|
|
1058
|
+
*/
|
|
1059
|
+
getOrchestratorState(): Record<string, unknown> | null {
|
|
1060
|
+
return this.getState(ManagedFile.ORCHESTRATOR);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Get autonomy state
|
|
1065
|
+
*/
|
|
1066
|
+
getAutonomyState(): Record<string, unknown> | null {
|
|
1067
|
+
return this.getState(ManagedFile.AUTONOMY);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Get queue state by type
|
|
1072
|
+
*/
|
|
1073
|
+
getQueueState(queueType: string = "pending"): Record<string, unknown> | null {
|
|
1074
|
+
const queueMap: Record<string, ManagedFileType> = {
|
|
1075
|
+
pending: ManagedFile.QUEUE_PENDING,
|
|
1076
|
+
"in-progress": ManagedFile.QUEUE_IN_PROGRESS,
|
|
1077
|
+
completed: ManagedFile.QUEUE_COMPLETED,
|
|
1078
|
+
failed: ManagedFile.QUEUE_FAILED,
|
|
1079
|
+
current: ManagedFile.QUEUE_CURRENT,
|
|
1080
|
+
};
|
|
1081
|
+
const fileRef = queueMap[queueType] || ManagedFile.QUEUE_PENDING;
|
|
1082
|
+
return this.getState(fileRef);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Get memory index
|
|
1087
|
+
*/
|
|
1088
|
+
getMemoryIndex(): Record<string, unknown> | null {
|
|
1089
|
+
return this.getState(ManagedFile.MEMORY_INDEX);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Set orchestrator state
|
|
1094
|
+
*/
|
|
1095
|
+
setOrchestratorState(
|
|
1096
|
+
state: Record<string, unknown>,
|
|
1097
|
+
source: string = "orchestrator"
|
|
1098
|
+
): StateChange {
|
|
1099
|
+
return this.setState(ManagedFile.ORCHESTRATOR, state, source);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Set autonomy state
|
|
1104
|
+
*/
|
|
1105
|
+
setAutonomyState(
|
|
1106
|
+
state: Record<string, unknown>,
|
|
1107
|
+
source: string = "autonomy"
|
|
1108
|
+
): StateChange {
|
|
1109
|
+
return this.setState(ManagedFile.AUTONOMY, state, source);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Update orchestrator phase
|
|
1114
|
+
*/
|
|
1115
|
+
updateOrchestratorPhase(phase: string, source: string = "orchestrator"): StateChange {
|
|
1116
|
+
return this.updateState(
|
|
1117
|
+
ManagedFile.ORCHESTRATOR,
|
|
1118
|
+
{ currentPhase: phase, lastUpdated: new Date().toISOString() },
|
|
1119
|
+
source
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Update autonomy status
|
|
1125
|
+
*/
|
|
1126
|
+
updateAutonomyStatus(status: string, source: string = "autonomy"): StateChange {
|
|
1127
|
+
return this.updateState(
|
|
1128
|
+
ManagedFile.AUTONOMY,
|
|
1129
|
+
{ status, lastRun: new Date().toISOString() },
|
|
1130
|
+
source
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Get all managed states
|
|
1136
|
+
*/
|
|
1137
|
+
getAllStates(): Record<string, Record<string, unknown>> {
|
|
1138
|
+
const states: Record<string, Record<string, unknown>> = {};
|
|
1139
|
+
|
|
1140
|
+
for (const [name, fileRef] of Object.entries(ManagedFile)) {
|
|
1141
|
+
const state = this.getState(fileRef);
|
|
1142
|
+
if (state !== null) {
|
|
1143
|
+
states[name] = state;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
return states;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Refresh all cached entries from disk
|
|
1152
|
+
*/
|
|
1153
|
+
refreshCache(): void {
|
|
1154
|
+
for (const [filePath] of this.cache) {
|
|
1155
|
+
if (fs.existsSync(filePath)) {
|
|
1156
|
+
const data = this.readFile(filePath);
|
|
1157
|
+
if (data) {
|
|
1158
|
+
this.putInCache(filePath, data);
|
|
1159
|
+
}
|
|
1160
|
+
} else {
|
|
1161
|
+
this.cache.delete(filePath);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// -------------------------------------------------------------------------
|
|
1167
|
+
// Optimistic Updates (SYN-014)
|
|
1168
|
+
// -------------------------------------------------------------------------
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Set the default conflict resolution strategy
|
|
1172
|
+
*/
|
|
1173
|
+
setConflictStrategy(strategy: ConflictStrategy): void {
|
|
1174
|
+
this.conflictStrategy = strategy;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Get the current version vector for a file
|
|
1179
|
+
*/
|
|
1180
|
+
getVersionVector(fileRef: string | ManagedFileType): VersionVector {
|
|
1181
|
+
const filePath = this.resolvePath(fileRef);
|
|
1182
|
+
|
|
1183
|
+
if (!this.versionVectors.has(filePath)) {
|
|
1184
|
+
// Try to load from file metadata
|
|
1185
|
+
const state = this.getState(fileRef);
|
|
1186
|
+
if (state && state._version_vector) {
|
|
1187
|
+
this.versionVectors.set(
|
|
1188
|
+
filePath,
|
|
1189
|
+
VersionVector.fromDict(state._version_vector as Record<string, number>)
|
|
1190
|
+
);
|
|
1191
|
+
} else {
|
|
1192
|
+
this.versionVectors.set(filePath, new VersionVector());
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
return this.versionVectors.get(filePath)!;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Apply an optimistic update immediately and queue for verification
|
|
1201
|
+
*
|
|
1202
|
+
* The update is applied to local state immediately but tracked as pending
|
|
1203
|
+
* until verified against the canonical state. If conflicts are detected
|
|
1204
|
+
* during verification, they are resolved using the configured strategy.
|
|
1205
|
+
*/
|
|
1206
|
+
optimisticUpdate(
|
|
1207
|
+
fileRef: string | ManagedFileType,
|
|
1208
|
+
key: string,
|
|
1209
|
+
value: unknown,
|
|
1210
|
+
source: string = "state-manager"
|
|
1211
|
+
): PendingUpdate {
|
|
1212
|
+
const filePath = this.resolvePath(fileRef);
|
|
1213
|
+
|
|
1214
|
+
// Get current version vector and increment for this source
|
|
1215
|
+
const versionVector = this.getVersionVector(fileRef);
|
|
1216
|
+
versionVector.increment(source);
|
|
1217
|
+
|
|
1218
|
+
// Create pending update
|
|
1219
|
+
const pending: PendingUpdate = {
|
|
1220
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
1221
|
+
key,
|
|
1222
|
+
value,
|
|
1223
|
+
source,
|
|
1224
|
+
timestamp: new Date().toISOString(),
|
|
1225
|
+
versionVector: VersionVector.fromDict(versionVector.toDict()),
|
|
1226
|
+
status: "pending",
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
// Track pending update
|
|
1230
|
+
if (!this.pendingUpdates.has(filePath)) {
|
|
1231
|
+
this.pendingUpdates.set(filePath, []);
|
|
1232
|
+
}
|
|
1233
|
+
this.pendingUpdates.get(filePath)!.push(pending);
|
|
1234
|
+
|
|
1235
|
+
// Apply optimistically to local state
|
|
1236
|
+
const currentState = this.getState(fileRef, {}) || {};
|
|
1237
|
+
currentState[key] = value;
|
|
1238
|
+
currentState._version_vector = versionVector.toDict();
|
|
1239
|
+
currentState._last_source = source;
|
|
1240
|
+
currentState._last_updated = pending.timestamp;
|
|
1241
|
+
|
|
1242
|
+
// Write state with version tracking
|
|
1243
|
+
this.writeFile(filePath, currentState);
|
|
1244
|
+
this.putInCache(filePath, currentState);
|
|
1245
|
+
|
|
1246
|
+
return pending;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Get all pending updates for a file
|
|
1251
|
+
*/
|
|
1252
|
+
getPendingUpdates(fileRef: string | ManagedFileType): PendingUpdate[] {
|
|
1253
|
+
const filePath = this.resolvePath(fileRef);
|
|
1254
|
+
return this.pendingUpdates.get(filePath) || [];
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Detect conflicts between local pending updates and remote state
|
|
1259
|
+
*/
|
|
1260
|
+
detectConflicts(
|
|
1261
|
+
fileRef: string | ManagedFileType,
|
|
1262
|
+
remoteState: Record<string, unknown>,
|
|
1263
|
+
remoteSource: string
|
|
1264
|
+
): ConflictInfo[] {
|
|
1265
|
+
const filePath = this.resolvePath(fileRef);
|
|
1266
|
+
const conflicts: ConflictInfo[] = [];
|
|
1267
|
+
const pending = this.pendingUpdates.get(filePath) || [];
|
|
1268
|
+
|
|
1269
|
+
if (pending.length === 0) {
|
|
1270
|
+
return conflicts;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Get remote version vector
|
|
1274
|
+
let remoteVv = new VersionVector();
|
|
1275
|
+
if (remoteState._version_vector) {
|
|
1276
|
+
remoteVv = VersionVector.fromDict(remoteState._version_vector as Record<string, number>);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const localState = this.getState(fileRef, {}) || {};
|
|
1280
|
+
|
|
1281
|
+
// Check each pending update for conflicts
|
|
1282
|
+
for (const update of pending) {
|
|
1283
|
+
if (update.status !== "pending") {
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const key = update.key;
|
|
1288
|
+
|
|
1289
|
+
// Check if same key was modified in remote state
|
|
1290
|
+
if (key in remoteState && key in localState) {
|
|
1291
|
+
const localVal = localState[key];
|
|
1292
|
+
const remoteVal = remoteState[key];
|
|
1293
|
+
|
|
1294
|
+
// Only conflict if values differ and versions are concurrent
|
|
1295
|
+
if (JSON.stringify(localVal) !== JSON.stringify(remoteVal)) {
|
|
1296
|
+
if (update.versionVector.concurrentWith(remoteVv)) {
|
|
1297
|
+
conflicts.push({
|
|
1298
|
+
key,
|
|
1299
|
+
localValue: localVal,
|
|
1300
|
+
remoteValue: remoteVal,
|
|
1301
|
+
localSource: update.source,
|
|
1302
|
+
remoteSource,
|
|
1303
|
+
localVersion: update.versionVector,
|
|
1304
|
+
remoteVersion: remoteVv,
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
return conflicts;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Resolve conflicts using the specified strategy
|
|
1316
|
+
*/
|
|
1317
|
+
resolveConflicts(
|
|
1318
|
+
fileRef: string | ManagedFileType,
|
|
1319
|
+
conflicts: ConflictInfo[],
|
|
1320
|
+
strategy?: ConflictStrategy
|
|
1321
|
+
): Record<string, unknown> {
|
|
1322
|
+
const resolveStrategy = strategy || this.conflictStrategy;
|
|
1323
|
+
const filePath = this.resolvePath(fileRef);
|
|
1324
|
+
|
|
1325
|
+
const localState = this.getState(fileRef, {}) || {};
|
|
1326
|
+
const resolvedState = { ...localState };
|
|
1327
|
+
|
|
1328
|
+
for (const conflict of conflicts) {
|
|
1329
|
+
if (resolveStrategy === ConflictStrategy.LAST_WRITE_WINS) {
|
|
1330
|
+
// Use remote value (assuming remote is more recent)
|
|
1331
|
+
resolvedState[conflict.key] = conflict.remoteValue;
|
|
1332
|
+
conflict.resolution = "last_write_wins";
|
|
1333
|
+
conflict.resolvedValue = conflict.remoteValue;
|
|
1334
|
+
} else if (resolveStrategy === ConflictStrategy.MERGE) {
|
|
1335
|
+
// Attempt to merge values
|
|
1336
|
+
const merged = this.mergeValues(
|
|
1337
|
+
conflict.localValue,
|
|
1338
|
+
conflict.remoteValue
|
|
1339
|
+
);
|
|
1340
|
+
resolvedState[conflict.key] = merged;
|
|
1341
|
+
conflict.resolution = "merged";
|
|
1342
|
+
conflict.resolvedValue = merged;
|
|
1343
|
+
} else if (resolveStrategy === ConflictStrategy.REJECT) {
|
|
1344
|
+
// Keep local value, mark conflict as rejected
|
|
1345
|
+
conflict.resolution = "rejected";
|
|
1346
|
+
conflict.resolvedValue = conflict.localValue;
|
|
1347
|
+
// Mark pending updates for this key as rejected
|
|
1348
|
+
const pending = this.pendingUpdates.get(filePath) || [];
|
|
1349
|
+
for (const update of pending) {
|
|
1350
|
+
if (update.key === conflict.key && update.status === "pending") {
|
|
1351
|
+
update.status = "rejected";
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// Merge version vectors
|
|
1358
|
+
let localVv = this.getVersionVector(fileRef);
|
|
1359
|
+
for (const conflict of conflicts) {
|
|
1360
|
+
localVv = localVv.merge(conflict.remoteVersion);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
resolvedState._version_vector = localVv.toDict();
|
|
1364
|
+
this.versionVectors.set(filePath, localVv);
|
|
1365
|
+
|
|
1366
|
+
return resolvedState;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Attempt to merge two values
|
|
1371
|
+
*
|
|
1372
|
+
* For objects, performs a deep merge.
|
|
1373
|
+
* For arrays, concatenates and deduplicates.
|
|
1374
|
+
* For other types, prefers remote value.
|
|
1375
|
+
*/
|
|
1376
|
+
private mergeValues(local: unknown, remote: unknown): unknown {
|
|
1377
|
+
if (
|
|
1378
|
+
typeof local === "object" &&
|
|
1379
|
+
typeof remote === "object" &&
|
|
1380
|
+
local !== null &&
|
|
1381
|
+
remote !== null &&
|
|
1382
|
+
!Array.isArray(local) &&
|
|
1383
|
+
!Array.isArray(remote)
|
|
1384
|
+
) {
|
|
1385
|
+
const merged: Record<string, unknown> = { ...(local as Record<string, unknown>) };
|
|
1386
|
+
for (const [key, value] of Object.entries(remote as Record<string, unknown>)) {
|
|
1387
|
+
if (key in merged) {
|
|
1388
|
+
merged[key] = this.mergeValues(merged[key], value);
|
|
1389
|
+
} else {
|
|
1390
|
+
merged[key] = value;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return merged;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (Array.isArray(local) && Array.isArray(remote)) {
|
|
1397
|
+
// Concatenate and deduplicate (preserving order)
|
|
1398
|
+
const seen = new Set<string>();
|
|
1399
|
+
const merged: unknown[] = [];
|
|
1400
|
+
for (const item of [...local, ...remote]) {
|
|
1401
|
+
const itemKey = JSON.stringify(item);
|
|
1402
|
+
if (!seen.has(itemKey)) {
|
|
1403
|
+
seen.add(itemKey);
|
|
1404
|
+
merged.push(item);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return merged;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// For scalars, prefer remote
|
|
1411
|
+
return remote;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Commit all pending updates for a file
|
|
1416
|
+
*/
|
|
1417
|
+
commitPendingUpdates(fileRef: string | ManagedFileType): number {
|
|
1418
|
+
const filePath = this.resolvePath(fileRef);
|
|
1419
|
+
const pending = this.pendingUpdates.get(filePath) || [];
|
|
1420
|
+
let committed = 0;
|
|
1421
|
+
|
|
1422
|
+
for (const update of pending) {
|
|
1423
|
+
if (update.status === "pending") {
|
|
1424
|
+
update.status = "committed";
|
|
1425
|
+
committed++;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// Clear committed updates
|
|
1430
|
+
this.pendingUpdates.set(
|
|
1431
|
+
filePath,
|
|
1432
|
+
pending.filter((u) => u.status !== "committed")
|
|
1433
|
+
);
|
|
1434
|
+
|
|
1435
|
+
return committed;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Rollback pending updates and restore original state
|
|
1440
|
+
*/
|
|
1441
|
+
rollbackPendingUpdates(
|
|
1442
|
+
fileRef: string | ManagedFileType,
|
|
1443
|
+
originalState: Record<string, unknown>
|
|
1444
|
+
): number {
|
|
1445
|
+
const filePath = this.resolvePath(fileRef);
|
|
1446
|
+
const pending = this.pendingUpdates.get(filePath) || [];
|
|
1447
|
+
let rolledBack = 0;
|
|
1448
|
+
|
|
1449
|
+
for (const update of pending) {
|
|
1450
|
+
if (update.status === "pending") {
|
|
1451
|
+
update.status = "rejected";
|
|
1452
|
+
rolledBack++;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Restore original state
|
|
1457
|
+
this.setState(fileRef, originalState, "rollback");
|
|
1458
|
+
|
|
1459
|
+
// Clear pending updates
|
|
1460
|
+
this.pendingUpdates.set(filePath, []);
|
|
1461
|
+
|
|
1462
|
+
return rolledBack;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Synchronize local state with remote state, resolving conflicts
|
|
1467
|
+
*
|
|
1468
|
+
* This is a high-level operation that:
|
|
1469
|
+
* 1. Detects conflicts between local pending updates and remote state
|
|
1470
|
+
* 2. Resolves conflicts using the specified strategy
|
|
1471
|
+
* 3. Commits or rejects pending updates accordingly
|
|
1472
|
+
* 4. Returns the final synchronized state
|
|
1473
|
+
*/
|
|
1474
|
+
syncWithRemote(
|
|
1475
|
+
fileRef: string | ManagedFileType,
|
|
1476
|
+
remoteState: Record<string, unknown>,
|
|
1477
|
+
remoteSource: string,
|
|
1478
|
+
strategy?: ConflictStrategy
|
|
1479
|
+
): {
|
|
1480
|
+
resolvedState: Record<string, unknown>;
|
|
1481
|
+
conflicts: ConflictInfo[];
|
|
1482
|
+
committed: number;
|
|
1483
|
+
} {
|
|
1484
|
+
// Detect conflicts
|
|
1485
|
+
const conflicts = this.detectConflicts(fileRef, remoteState, remoteSource);
|
|
1486
|
+
|
|
1487
|
+
// Resolve conflicts
|
|
1488
|
+
const resolvedState = this.resolveConflicts(fileRef, conflicts, strategy);
|
|
1489
|
+
|
|
1490
|
+
// Apply resolved state
|
|
1491
|
+
this.setState(fileRef, resolvedState, "sync");
|
|
1492
|
+
|
|
1493
|
+
// Commit pending updates (non-rejected ones)
|
|
1494
|
+
const committed = this.commitPendingUpdates(fileRef);
|
|
1495
|
+
|
|
1496
|
+
return { resolvedState, conflicts, committed };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// -------------------------------------------------------------------------
|
|
1500
|
+
// State Versioning (SYN-015)
|
|
1501
|
+
// -------------------------------------------------------------------------
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Get a safe key for the file reference (used in history paths)
|
|
1505
|
+
*/
|
|
1506
|
+
private getFileKey(fileRef: string | ManagedFileType): string {
|
|
1507
|
+
const relPath = typeof fileRef === "string" ? fileRef : fileRef;
|
|
1508
|
+
return relPath.replace(/\//g, "_").replace(/\\/g, "_").replace(".json", "");
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Get the history directory for a file reference
|
|
1513
|
+
*/
|
|
1514
|
+
private getHistoryDir(fileRef: string | ManagedFileType): string {
|
|
1515
|
+
const fileKey = this.getFileKey(fileRef);
|
|
1516
|
+
return path.join(this.lokiDir, "state", "history", fileKey);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Get the next version number for a file
|
|
1521
|
+
*/
|
|
1522
|
+
private getNextVersion(fileRef: string | ManagedFileType): number {
|
|
1523
|
+
const fileKey = this.getFileKey(fileRef);
|
|
1524
|
+
if (!this.versionCounters.has(fileKey)) {
|
|
1525
|
+
// Initialize from existing versions on disk
|
|
1526
|
+
const historyDir = this.getHistoryDir(fileRef);
|
|
1527
|
+
if (fs.existsSync(historyDir)) {
|
|
1528
|
+
const files = fs.readdirSync(historyDir).filter(f => f.endsWith(".json"));
|
|
1529
|
+
if (files.length > 0) {
|
|
1530
|
+
let maxVersion = 0;
|
|
1531
|
+
for (const f of files) {
|
|
1532
|
+
const version = parseInt(path.basename(f, ".json"), 10);
|
|
1533
|
+
if (!isNaN(version) && version > maxVersion) {
|
|
1534
|
+
maxVersion = version;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
this.versionCounters.set(fileKey, maxVersion);
|
|
1538
|
+
} else {
|
|
1539
|
+
this.versionCounters.set(fileKey, 0);
|
|
1540
|
+
}
|
|
1541
|
+
} else {
|
|
1542
|
+
this.versionCounters.set(fileKey, 0);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
const current = this.versionCounters.get(fileKey) || 0;
|
|
1546
|
+
this.versionCounters.set(fileKey, current + 1);
|
|
1547
|
+
return current + 1;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Save a version of the state to history
|
|
1552
|
+
*/
|
|
1553
|
+
private saveVersion(
|
|
1554
|
+
fileRef: string | ManagedFileType,
|
|
1555
|
+
data: Record<string, unknown>,
|
|
1556
|
+
source: string,
|
|
1557
|
+
changeType: string
|
|
1558
|
+
): number {
|
|
1559
|
+
const historyDir = this.getHistoryDir(fileRef);
|
|
1560
|
+
if (!fs.existsSync(historyDir)) {
|
|
1561
|
+
fs.mkdirSync(historyDir, { recursive: true });
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const version = this.getNextVersion(fileRef);
|
|
1565
|
+
const timestamp = new Date().toISOString();
|
|
1566
|
+
|
|
1567
|
+
const versionData: StateVersion = {
|
|
1568
|
+
version,
|
|
1569
|
+
timestamp,
|
|
1570
|
+
data,
|
|
1571
|
+
source,
|
|
1572
|
+
changeType,
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1575
|
+
const versionPath = path.join(historyDir, `${version}.json`);
|
|
1576
|
+
this.writeFile(versionPath, versionData as unknown as Record<string, unknown>);
|
|
1577
|
+
|
|
1578
|
+
// Clean up old versions
|
|
1579
|
+
this.cleanupOldVersions(fileRef);
|
|
1580
|
+
|
|
1581
|
+
return version;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Remove versions beyond the retention limit
|
|
1586
|
+
*/
|
|
1587
|
+
private cleanupOldVersions(fileRef: string | ManagedFileType): void {
|
|
1588
|
+
const historyDir = this.getHistoryDir(fileRef);
|
|
1589
|
+
if (!fs.existsSync(historyDir)) {
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const files = fs.readdirSync(historyDir).filter(f => f.endsWith(".json"));
|
|
1594
|
+
if (files.length <= this.versionRetention) {
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// Sort by version number and remove oldest
|
|
1599
|
+
const versionFiles = files
|
|
1600
|
+
.map(f => ({ file: f, version: parseInt(path.basename(f, ".json"), 10) }))
|
|
1601
|
+
.filter(v => !isNaN(v.version))
|
|
1602
|
+
.sort((a, b) => a.version - b.version);
|
|
1603
|
+
|
|
1604
|
+
const toRemove = versionFiles.slice(0, versionFiles.length - this.versionRetention);
|
|
1605
|
+
for (const { file } of toRemove) {
|
|
1606
|
+
try {
|
|
1607
|
+
fs.unlinkSync(path.join(historyDir, file));
|
|
1608
|
+
} catch {
|
|
1609
|
+
// Ignore removal errors
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Get version history for a state file
|
|
1616
|
+
*/
|
|
1617
|
+
getVersionHistory(fileRef: string | ManagedFileType): VersionInfo[] {
|
|
1618
|
+
const historyDir = this.getHistoryDir(fileRef);
|
|
1619
|
+
if (!fs.existsSync(historyDir)) {
|
|
1620
|
+
return [];
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const versions: VersionInfo[] = [];
|
|
1624
|
+
const files = fs.readdirSync(historyDir).filter(f => f.endsWith(".json"));
|
|
1625
|
+
|
|
1626
|
+
for (const file of files) {
|
|
1627
|
+
try {
|
|
1628
|
+
const versionNum = parseInt(path.basename(file, ".json"), 10);
|
|
1629
|
+
if (isNaN(versionNum)) continue;
|
|
1630
|
+
|
|
1631
|
+
const versionPath = path.join(historyDir, file);
|
|
1632
|
+
const data = this.readFile(versionPath);
|
|
1633
|
+
if (data) {
|
|
1634
|
+
const versionEntry = data as unknown as StateVersion;
|
|
1635
|
+
versions.push({
|
|
1636
|
+
version: versionNum,
|
|
1637
|
+
timestamp: versionEntry.timestamp || "",
|
|
1638
|
+
source: versionEntry.source || "unknown",
|
|
1639
|
+
changeType: versionEntry.changeType || "update",
|
|
1640
|
+
dataHash: computeHash(versionEntry.data || {}),
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
} catch {
|
|
1644
|
+
// Skip invalid version files
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Sort by version descending (newest first)
|
|
1649
|
+
versions.sort((a, b) => b.version - a.version);
|
|
1650
|
+
return versions;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* Get state data at a specific version without restoring
|
|
1655
|
+
*/
|
|
1656
|
+
getStateAtVersion(
|
|
1657
|
+
fileRef: string | ManagedFileType,
|
|
1658
|
+
version: number
|
|
1659
|
+
): Record<string, unknown> | null {
|
|
1660
|
+
const historyDir = this.getHistoryDir(fileRef);
|
|
1661
|
+
const versionPath = path.join(historyDir, `${version}.json`);
|
|
1662
|
+
|
|
1663
|
+
if (!fs.existsSync(versionPath)) {
|
|
1664
|
+
return null;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
const versionData = this.readFile(versionPath);
|
|
1668
|
+
if (versionData) {
|
|
1669
|
+
const versionEntry = versionData as unknown as StateVersion;
|
|
1670
|
+
return versionEntry.data || null;
|
|
1671
|
+
}
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Restore state to a specific version
|
|
1677
|
+
*/
|
|
1678
|
+
rollback(
|
|
1679
|
+
fileRef: string | ManagedFileType,
|
|
1680
|
+
version: number,
|
|
1681
|
+
source: string = "rollback"
|
|
1682
|
+
): StateChange | null {
|
|
1683
|
+
const data = this.getStateAtVersion(fileRef, version);
|
|
1684
|
+
if (data === null) {
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// Save current state as a version before rollback
|
|
1689
|
+
const current = this.getState(fileRef);
|
|
1690
|
+
if (current !== null && this.enableVersioning) {
|
|
1691
|
+
this.saveVersion(fileRef, current, source, "pre_rollback");
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Set the restored state (saveVersion=false since we already saved)
|
|
1695
|
+
return this.setState(fileRef, data, source, false);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Get the number of versions stored for a file
|
|
1700
|
+
*/
|
|
1701
|
+
getVersionCount(fileRef: string | ManagedFileType): number {
|
|
1702
|
+
const historyDir = this.getHistoryDir(fileRef);
|
|
1703
|
+
if (!fs.existsSync(historyDir)) {
|
|
1704
|
+
return 0;
|
|
1705
|
+
}
|
|
1706
|
+
return fs.readdirSync(historyDir).filter(f => f.endsWith(".json")).length;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* Clear all version history for a file
|
|
1711
|
+
*/
|
|
1712
|
+
clearVersionHistory(fileRef: string | ManagedFileType): number {
|
|
1713
|
+
const historyDir = this.getHistoryDir(fileRef);
|
|
1714
|
+
if (!fs.existsSync(historyDir)) {
|
|
1715
|
+
return 0;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
const files = fs.readdirSync(historyDir).filter(f => f.endsWith(".json"));
|
|
1719
|
+
let count = 0;
|
|
1720
|
+
for (const file of files) {
|
|
1721
|
+
try {
|
|
1722
|
+
fs.unlinkSync(path.join(historyDir, file));
|
|
1723
|
+
count++;
|
|
1724
|
+
} catch {
|
|
1725
|
+
// Ignore removal errors
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// Reset version counter
|
|
1730
|
+
const fileKey = this.getFileKey(fileRef);
|
|
1731
|
+
this.versionCounters.delete(fileKey);
|
|
1732
|
+
|
|
1733
|
+
return count;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* Update the version retention limit
|
|
1738
|
+
*/
|
|
1739
|
+
setVersionRetention(retention: number): void {
|
|
1740
|
+
if (retention < 1) {
|
|
1741
|
+
throw new Error("Version retention must be at least 1");
|
|
1742
|
+
}
|
|
1743
|
+
this.versionRetention = retention;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Singleton instance
|
|
1748
|
+
let defaultManager: StateManager | null = null;
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Get the default state manager instance
|
|
1752
|
+
*/
|
|
1753
|
+
export function getStateManager(options?: {
|
|
1754
|
+
lokiDir?: string;
|
|
1755
|
+
enableWatch?: boolean;
|
|
1756
|
+
enableEvents?: boolean;
|
|
1757
|
+
enableVersioning?: boolean;
|
|
1758
|
+
versionRetention?: number;
|
|
1759
|
+
}): StateManager {
|
|
1760
|
+
if (defaultManager === null) {
|
|
1761
|
+
defaultManager = new StateManager(options);
|
|
1762
|
+
}
|
|
1763
|
+
return defaultManager;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* Reset the default state manager (for testing)
|
|
1768
|
+
*/
|
|
1769
|
+
export function resetStateManager(): void {
|
|
1770
|
+
if (defaultManager) {
|
|
1771
|
+
defaultManager.stop();
|
|
1772
|
+
defaultManager = null;
|
|
1773
|
+
}
|
|
1774
|
+
}
|