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.
Files changed (64) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/app-runner.sh +34 -8
  4. package/autonomy/completion-council.sh +70 -32
  5. package/autonomy/issue-parser.sh +4 -7
  6. package/autonomy/loki +238 -119
  7. package/autonomy/notification-checker.py +49 -23
  8. package/autonomy/run.sh +162 -79
  9. package/autonomy/sandbox.sh +91 -24
  10. package/bin/loki-mode.js +1 -2
  11. package/bin/postinstall.js +10 -4
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/control.py +46 -36
  14. package/dashboard/database.py +21 -4
  15. package/dashboard/server.py +107 -78
  16. package/docs/BUG-AUDIT-v6.61.0.md +957 -0
  17. package/docs/INSTALLATION.md +2 -2
  18. package/events/bus.py +129 -28
  19. package/events/bus.ts +41 -27
  20. package/events/emit.sh +1 -1
  21. package/integrations/openclaw/README.md +139 -0
  22. package/integrations/openclaw/SKILL.md +88 -0
  23. package/integrations/openclaw/bridge/__init__.py +1 -0
  24. package/integrations/openclaw/bridge/__main__.py +88 -0
  25. package/integrations/openclaw/bridge/schema_map.py +180 -0
  26. package/integrations/openclaw/bridge/watcher.py +100 -0
  27. package/integrations/openclaw/scripts/format-progress.sh +80 -0
  28. package/integrations/openclaw/scripts/poll-status.sh +74 -0
  29. package/integrations/vibe-kanban.md +289 -0
  30. package/mcp/__init__.py +1 -1
  31. package/mcp/server.py +96 -73
  32. package/memory/consolidation.py +21 -6
  33. package/memory/engine.py +53 -26
  34. package/memory/layers/index_layer.py +16 -3
  35. package/memory/layers/timeline_layer.py +16 -3
  36. package/memory/retrieval.py +4 -1
  37. package/memory/schemas.py +4 -2
  38. package/memory/storage.py +25 -4
  39. package/memory/token_economics.py +9 -2
  40. package/memory/vector_index.py +2 -2
  41. package/package.json +3 -1
  42. package/providers/cline.sh +5 -4
  43. package/providers/codex.sh +27 -5
  44. package/providers/gemini.sh +59 -23
  45. package/providers/loader.sh +3 -2
  46. package/skills/parallel-workflows.md +9 -7
  47. package/state/__init__.py +10 -0
  48. package/state/index.ts +18 -0
  49. package/state/manager.py +1801 -0
  50. package/state/manager.ts +1774 -0
  51. package/state/sqlite_backend.py +188 -0
  52. package/state/test_manager.py +703 -0
  53. package/state/test_manager.ts +366 -0
  54. package/templates/README.md +19 -4
  55. package/templates/dashboard.md +45 -0
  56. package/templates/data-pipeline.md +45 -0
  57. package/templates/game.md +48 -0
  58. package/templates/microservice.md +49 -0
  59. package/templates/npm-library.md +42 -0
  60. package/templates/rest-api.md +170 -33
  61. package/templates/slack-bot.md +48 -0
  62. package/templates/web-scraper.md +45 -0
  63. package/web-app/server.py +360 -191
  64. package/templates/saas-app.md +0 -42
@@ -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
+ }