principles-disciple 1.51.0 → 1.53.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.
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.51.0",
5
+ "version": "1.53.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.51.0",
3
+ "version": "1.53.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -18,10 +18,20 @@ import { fileURLToPath } from 'url';
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = dirname(__filename);
20
20
 
21
+ /**
22
+ * Cross-platform home directory (Linux: HOME, Windows: USERPROFILE/HOMEDRIVE+HOMEPATH)
23
+ */
24
+ function getHomeDir() {
25
+ return process.env.HOME
26
+ || process.env.USERPROFILE
27
+ || (process.env.HOMEDRIVE && process.env.HOMEPATH ? process.env.HOMEDRIVE + process.env.HOMEPATH : null)
28
+ || '.';
29
+ }
30
+
21
31
  // Resolve workspace directory: CLI arg > env var > default
22
32
  const WORKSPACE_DIR = process.argv[2]
23
33
  || process.env.WORKSPACE_DIR
24
- || join(process.env.HOME, '.openclaw', 'workspace-main');
34
+ || join(getHomeDir(), '.openclaw', 'workspace');
25
35
 
26
36
  const STATE_DIR = join(WORKSPACE_DIR, '.state');
27
37
 
@@ -27,8 +27,36 @@ const __filename = fileURLToPath(import.meta.url);
27
27
  const __dirname = dirname(__filename);
28
28
 
29
29
  const SOURCE_DIR = join(__dirname, '..');
30
- const OPENCLAW_DIR = join(process.env.HOME, '.openclaw');
30
+
31
+ /**
32
+ * Cross-platform home directory resolution.
33
+ * Linux/macOS: HOME=/home/user
34
+ * Windows: USERPROFILE=C:\Users\user or HOMEDRIVE/HOMEPATH
35
+ */
36
+ function getHomeDir() {
37
+ return process.env.HOME
38
+ || process.env.USERPROFILE
39
+ || (process.env.HOMEDRIVE && process.env.HOMEPATH ? process.env.HOMEDRIVE + process.env.HOMEPATH : null)
40
+ || '.';
41
+ }
42
+
43
+ const OPENCLAW_DIR = join(getHomeDir(), '.openclaw');
31
44
  const INSTALL_DIR = join(OPENCLAW_DIR, 'extensions', 'principles-disciple');
45
+ function getConfiguredWorkspaceDir() {
46
+ const configPath = join(OPENCLAW_DIR, 'openclaw.json');
47
+ try {
48
+ const raw = readFileSync(configPath, 'utf-8');
49
+ const config = JSON.parse(raw);
50
+ const workspace = config?.agents?.defaults?.workspace;
51
+ if (workspace && existsSync(workspace)) {
52
+ return workspace;
53
+ }
54
+ } catch {
55
+ // Fall through to fallback
56
+ }
57
+ // Fallback: try workspace-main (legacy default)
58
+ return join(OPENCLAW_DIR, 'workspace-main');
59
+ }
32
60
 
33
61
  // Files and directories to sync
34
62
  const SYNC_ITEMS = [
@@ -177,7 +205,11 @@ function checkPrerequisites() {
177
205
  // Check for global package conflicts that cause module resolution traps
178
206
  console.log('🔍 Checking for global package conflicts...');
179
207
  try {
180
- const globalConflict = execSync('npm list -g principles-disciple --depth=0 2>/dev/null', { encoding: 'utf-8' });
208
+ // Cross-platform: use stdio: 'pipe' to capture stderr, then check output
209
+ const globalConflict = execSync('npm list -g principles-disciple --depth=0', {
210
+ encoding: 'utf-8',
211
+ stdio: ['pipe', 'pipe', 'pipe'] // Capture all streams
212
+ });
181
213
  if (globalConflict.includes('principles-disciple')) {
182
214
  console.error('\n❌ CONFLICT DETECTED: A version of "principles-disciple" is installed globally via npm.');
183
215
  console.error('This will block OpenClaw from loading the extension version you are trying to install.');
@@ -187,7 +219,17 @@ function checkPrerequisites() {
187
219
  }
188
220
  } catch (e) {
189
221
  // npm list returns non-zero if not found, which is what we want
190
- console.log('✅ No global package conflicts detected.');
222
+ // Check if the error output contains the package name
223
+ const output = e.stdout || e.stderr || '';
224
+ if (!output.includes('principles-disciple')) {
225
+ console.log('✅ No global package conflicts detected.');
226
+ } else {
227
+ console.error('\n❌ CONFLICT DETECTED: A version of "principles-disciple" is installed globally via npm.');
228
+ console.error('This will block OpenClaw from loading the extension version you are trying to install.');
229
+ console.error('\nACTION REQUIRED: Please run the following command first:');
230
+ console.error(' npm uninstall -g principles-disciple\n');
231
+ process.exit(1);
232
+ }
191
233
  }
192
234
  } catch {
193
235
  console.error('❌ npm not found. Please install Node.js with npm.');
@@ -481,7 +523,7 @@ function verifyInstalledFingerprint() {
481
523
  }
482
524
 
483
525
  /**
484
- * Remove existing installation directory.
526
+ * Remove existing installation directory with Windows-friendly retry logic.
485
527
  */
486
528
  function cleanTargetDir(force) {
487
529
  if (!existsSync(INSTALL_DIR)) return;
@@ -495,7 +537,34 @@ function cleanTargetDir(force) {
495
537
  }
496
538
 
497
539
  console.log('\n🗑️ Removing existing installation...');
498
- rmSync(INSTALL_DIR, { recursive: true, force: true });
540
+
541
+ // Windows often returns EPERM due to file locks, add retry logic
542
+ const maxRetries = isWindows() ? 3 : 1;
543
+ let lastError = null;
544
+
545
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
546
+ try {
547
+ rmSync(INSTALL_DIR, { recursive: true, force: true });
548
+ console.log(' ✅ Removed successfully.');
549
+ return;
550
+ } catch (err) {
551
+ lastError = err;
552
+ if (err.code === 'EPERM' && attempt < maxRetries) {
553
+ console.log(` ⚠️ Attempt ${attempt}/${maxRetries} failed (EPERM), retrying in 2s...`);
554
+ // Synchronous sleep for retry
555
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
556
+ }
557
+ }
558
+ }
559
+
560
+ // If all retries failed on Windows, try graceful fallback
561
+ if (isWindows() && lastError?.code === 'EPERM') {
562
+ console.log(' ⚠️ Windows file lock detected, skipping removal.');
563
+ console.log(' 📁 Will overwrite files in place.');
564
+ return; // Continue with overwrite installation
565
+ }
566
+
567
+ throw lastError;
499
568
  }
500
569
 
501
570
  /**
@@ -573,17 +642,115 @@ function cleanStaleBackups() {
573
642
  }
574
643
 
575
644
  /**
576
- * Restart OpenClaw Gateway.
645
+ * Check if running on Windows.
646
+ */
647
+ function isWindows() {
648
+ return process.platform === 'win32';
649
+ }
650
+
651
+ /**
652
+ * Get temporary directory path (cross-platform).
653
+ */
654
+ function getTempDir() {
655
+ if (isWindows()) {
656
+ return process.env.TEMP || process.env.TMP || 'C:\\Windows\\Temp';
657
+ }
658
+ return '/tmp';
659
+ }
660
+
661
+ /**
662
+ * Restart OpenClaw Gateway (cross-platform).
577
663
  */
578
664
  function restartGateway() {
579
665
  console.log('\n🔄 Restarting OpenClaw Gateway...');
666
+
667
+ if (isWindows()) {
668
+ return restartGatewayWindows();
669
+ } else {
670
+ return restartGatewayLinux();
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Restart Gateway on Windows using PowerShell.
676
+ */
677
+ function restartGatewayWindows() {
678
+ const logPath = join(getTempDir(), 'openclaw-auto-restart.log');
679
+
580
680
  try {
681
+ // Step 1: Find and terminate existing gateway processes
682
+ console.log(' Looking for existing gateway processes...');
683
+ try {
684
+ // PowerShell command to find and kill openclaw gateway processes
685
+ // Note: Use single quotes inside -like pattern for proper escaping
686
+ const findCmd = "Get-Process -Name 'node' -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*openclaw*' } | Select-Object -ExpandProperty Id";
687
+ const pids = execSync(`powershell -NoProfile -Command "${findCmd}"`, { encoding: 'utf-8' }).trim();
688
+
689
+ if (pids) {
690
+ console.log(` Terminating existing gateway process(es): ${pids.replace(/\n/g, ', ')}...`);
691
+ // Kill by PID
692
+ const pidList = pids.split('\n').filter(p => p.trim());
693
+ for (const pid of pidList) {
694
+ try {
695
+ execSync(`taskkill /PID ${pid.trim()} /F`, { stdio: 'pipe' });
696
+ } catch { /* ignore if process already gone */ }
697
+ }
698
+ // Wait a moment for process to terminate
699
+ execSync('timeout /t 3 /nobreak > nul', { shell: true, stdio: 'ignore' });
700
+ }
701
+ } catch { /* no existing processes */ }
702
+
703
+ // Step 2: Start new gateway process in background
704
+ console.log(` Starting new gateway (logs: ${logPath})...`);
705
+
706
+ // Use openclaw CLI to start gateway (more reliable than direct node invocation)
707
+ const gatewayCmd = join(getHomeDir(), '.openclaw', 'gateway.cmd');
708
+ const startCmd = `Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${gatewayCmd}' -WindowStyle Hidden -RedirectStandardOutput '${logPath}' -RedirectStandardError '${join(getTempDir(), 'openclaw-auto-restart.err')}'`;
709
+ execSync(`powershell -NoProfile -Command "${startCmd}"`, { stdio: 'inherit' });
710
+ console.log('✅ Gateway restart triggered.');
711
+
712
+ // Step 3: Wait and verify
713
+ setTimeout(() => {
714
+ try {
715
+ if (existsSync(logPath)) {
716
+ const logs = readFileSync(logPath, 'utf-8');
717
+ if (logs.includes('Principles Disciple Plugin registered')) {
718
+ console.log('✅ SUCCESS: Principles Disciple plugin registered successfully!');
719
+ } else if (logs.includes('failed to load') || logs.includes('Error: Cannot find module')) {
720
+ console.error('\n❌ CRITICAL: Gateway started but PD plugin FAILED to load!');
721
+ console.error(' Check logs at: ' + logPath);
722
+ process.exit(1);
723
+ } else {
724
+ console.warn('⚠️ Gateway started but PD registration not confirmed in recent logs.');
725
+ console.log(' Check logs at: ' + logPath);
726
+ }
727
+ }
728
+ } catch (e) {
729
+ console.warn(`⚠️ Post-restart verification skipped: ${e.message}`);
730
+ }
731
+ }, 8000);
732
+
733
+ } catch (error) {
734
+ console.error(`\n❌ Failed to restart gateway: ${error.message}`);
735
+ console.error(' You may need to manually restart OpenClaw Gateway.');
736
+ process.exit(1);
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Restart Gateway on Linux using systemctl or process management.
742
+ */
743
+ function restartGatewayLinux() {
744
+ const logPath = '/tmp/openclaw-auto-restart.log';
745
+
746
+ try {
747
+ // Try systemctl first (Linux systemd)
581
748
  try {
582
749
  execSync('systemctl --user is-active openclaw-gateway.service', { stdio: 'pipe' });
583
750
  console.log(' Restarting via systemctl...');
584
751
  execSync('systemctl --user restart openclaw-gateway.service', { stdio: 'inherit' });
585
752
  console.log('✅ Gateway restarted via systemctl.');
586
-
753
+
587
754
  console.log(' Waiting for Gateway to initialize and load PD plugin (8s)...');
588
755
  setTimeout(() => {
589
756
  try {
@@ -608,8 +775,9 @@ function restartGateway() {
608
775
  }
609
776
  }, 8000);
610
777
  return;
611
- } catch { /* ignore */ }
778
+ } catch { /* systemctl not available, fall through to manual restart */ }
612
779
 
780
+ // Manual process management
613
781
  const pids = execSync('pgrep -f "openclaw-gateway|openclaw gateway"', { encoding: 'utf-8' }).trim();
614
782
  if (pids) {
615
783
  console.log(` Terminating existing gateway process(es)...`);
@@ -617,11 +785,10 @@ function restartGateway() {
617
785
  execSync('sleep 3');
618
786
  }
619
787
 
620
- const logPath = '/tmp/openclaw-auto-restart.log';
621
788
  console.log(` Starting new gateway (logs: ${logPath})...`);
622
789
  execSync(`nohup openclaw gateway --force > ${logPath} 2>&1 &`, { stdio: 'ignore' });
623
790
  console.log('✅ Gateway restart triggered.');
624
-
791
+
625
792
  setTimeout(() => {
626
793
  if (existsSync(logPath)) {
627
794
  const logs = readFileSync(logPath, 'utf-8');
@@ -699,9 +866,10 @@ function main() {
699
866
  if (existsSync(bootstrapScript)) {
700
867
  console.log('\n🧠 Synchronizing principles to active rules (Bootstrap)...');
701
868
  try {
702
- const targetStateDir = join(process.env.HOME, '.openclaw', 'workspace-main', '.state');
869
+ const workspaceDir = getConfiguredWorkspaceDir();
870
+ const targetStateDir = join(workspaceDir, '.state');
703
871
  if (existsSync(targetStateDir)) {
704
- execSync(`STATE_DIR=${targetStateDir} BOOTSTRAP_LIMIT=100 node scripts/bootstrap-rules.mjs`, { cwd: SOURCE_DIR, stdio: 'inherit' });
872
+ execSync(`node scripts/bootstrap-rules.mjs`, { cwd: SOURCE_DIR, stdio: 'inherit', env: { ...process.env, STATE_DIR: targetStateDir, BOOTSTRAP_LIMIT: '100' } });
705
873
  console.log('✅ Principles synchronized.');
706
874
  }
707
875
  } catch (e) {
@@ -713,7 +881,8 @@ function main() {
713
881
  if (existsSync(compileScript)) {
714
882
  console.log('\n⚙️ Compiling pain-derived principles into rules...');
715
883
  try {
716
- const targetWorkspaceDir = join(process.env.HOME, '.openclaw', 'workspace-main');
884
+ const workspaceDir = getConfiguredWorkspaceDir();
885
+ const targetWorkspaceDir = workspaceDir;
717
886
  if (existsSync(targetWorkspaceDir)) {
718
887
  execSync(`node scripts/compile-principles.mjs ${targetWorkspaceDir}`, { cwd: SOURCE_DIR, stdio: 'inherit' });
719
888
  console.log('✅ Principle compilation complete.');
@@ -732,12 +901,6 @@ function main() {
732
901
  verifyInstalledFingerprint();
733
902
  if (args.dev || args.restart) cleanStaleBackups();
734
903
 
735
- try {
736
- const reloadSignal = join(OPENCLAW_DIR, '.plugin_reload_signal');
737
- writeFileSync(reloadSignal, new Date().toISOString(), 'utf-8');
738
- console.log(`\n🔔 Reload signal sent to ${reloadSignal}`);
739
- } catch { /* ignore */ }
740
-
741
904
  console.log('\n╔════════════════════════════════════════════════════════════╗');
742
905
  console.log('║ ✅ Installation Complete ║');
743
906
  console.log('╚════════════════════════════════════════════════════════════╝');
@@ -746,6 +909,7 @@ function main() {
746
909
  restartGateway();
747
910
  } else {
748
911
  console.log('\n💡 Restart OpenClaw Gateway to load the new version.');
912
+ console.log(' (Plugin code changes require a full gateway restart)');
749
913
  }
750
914
  }
751
915
 
@@ -13,8 +13,10 @@
13
13
  * npm run bootstrap-rules (production)
14
14
  */
15
15
 
16
- import { loadLedger, createRule, updatePrinciple } from './principle-tree-ledger.js';
16
+ import { loadLedger, createRule, updatePrinciple, addPrincipleToLedger } from './principle-tree-ledger.js';
17
+ import type { LedgerPrinciple } from './principle-tree-ledger.js';
17
18
  import { loadStore } from './principle-training-state.js';
19
+ import { CORE_THINKING_MODELS } from './init.js';
18
20
 
19
21
  export interface BootstrapResult {
20
22
  principleId: string;
@@ -77,12 +79,47 @@ export function selectPrinciplesForBootstrap(stateDir: string, limit = 3): strin
77
79
  * @throws Error if no deterministic principles found
78
80
  */
79
81
  export function bootstrapRules(stateDir: string, limit = 3): BootstrapResult[] {
82
+ // Migration: if T-01..T-10 exist in Training Store but not in Ledger Tree, backfill.
83
+ // This handles workspaces initialized before Ledger Tree was added.
84
+ const store = loadStore(stateDir);
85
+ const ledger = loadLedger(stateDir);
86
+ const hasTrainingT = Object.keys(store).some((id) => id.startsWith('T-'));
87
+ const hasAnyLedgerT = Object.keys(ledger.tree.principles).some((id) => id.startsWith('T-'));
88
+ if (hasTrainingT && !hasAnyLedgerT) {
89
+ console.warn('[bootstrap] Migrating T-01..T-10 from Training Store to Ledger Tree');
90
+ const now = new Date().toISOString();
91
+ for (const [id, entry] of Object.entries(store)) {
92
+ if (!id.startsWith('T-')) continue;
93
+ const model = CORE_THINKING_MODELS.find((m) => m.id === id);
94
+ if (!model) continue;
95
+ const lp: LedgerPrinciple = {
96
+ id,
97
+ version: 1,
98
+ text: model.description,
99
+ coreAxiomId: id,
100
+ triggerPattern: '',
101
+ action: '',
102
+ status: 'active',
103
+ priority: 'P1',
104
+ scope: 'general',
105
+ evaluability: entry.evaluability,
106
+ valueScore: 0,
107
+ adherenceRate: 0,
108
+ painPreventedCount: 0,
109
+ derivedFromPainIds: [],
110
+ ruleIds: [],
111
+ conflictsWithPrincipleIds: [],
112
+ createdAt: now,
113
+ updatedAt: now,
114
+ suggestedRules: [],
115
+ };
116
+ addPrincipleToLedger(stateDir, lp);
117
+ }
118
+ }
119
+
80
120
  // Select principles for bootstrap
81
121
  const selectedPrincipleIds = selectPrinciplesForBootstrap(stateDir, limit);
82
122
 
83
- // Load current ledger state
84
- const ledger = loadLedger(stateDir);
85
-
86
123
  const results: BootstrapResult[] = [];
87
124
 
88
125
  for (const principleId of selectedPrincipleIds) {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * EvolutionHook interface for the Evolution SDK.
3
+ *
4
+ * Provides a callback-based interface for observing evolution lifecycle
5
+ * events: pain detection, principle creation, and principle promotion.
6
+ *
7
+ * Per D-03, this interface contains only the 3 core event methods.
8
+ * Per D-04, consumers implement the interface directly (no EventEmitter).
9
+ * Hooks not needed can use the provided noOpEvolutionHook and override
10
+ * individual methods.
11
+ */
12
+ import type { PainSignal } from './pain-signal.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Event Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Event payload for principle creation lifecycle events. */
19
+ export interface PrincipleCreatedEvent {
20
+ /** Unique principle identifier */
21
+ id: string;
22
+ /** Principle text ("When X, then Y.") */
23
+ text: string;
24
+ /** What triggered this principle's creation */
25
+ trigger: string;
26
+ }
27
+
28
+ /** Event payload for principle promotion lifecycle events. */
29
+ export interface PrinciplePromotedEvent {
30
+ /** Unique principle identifier */
31
+ id: string;
32
+ /** Previous status tier */
33
+ from: string;
34
+ /** New status tier */
35
+ to: string;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // EvolutionHook Interface
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Callback interface for observing evolution lifecycle events.
44
+ *
45
+ * Implement all 3 methods, or spread noOpEvolutionHook and override
46
+ * only the methods you need:
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * const myHook: EvolutionHook = {
51
+ * ...noOpEvolutionHook,
52
+ * onPainDetected(signal) { console.log(signal); },
53
+ * };
54
+ * ```
55
+ */
56
+ export interface EvolutionHook {
57
+ /** Called when a pain signal is detected and recorded. */
58
+ onPainDetected(signal: PainSignal): void;
59
+ /** Called when a new principle candidate is created. */
60
+ onPrincipleCreated(event: PrincipleCreatedEvent): void;
61
+ /** Called when a principle is promoted to a higher tier. */
62
+ onPrinciplePromoted(event: PrinciplePromotedEvent): void;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // No-op Helper
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /** No-op implementation -- consumers can spread and override individual methods. */
70
+ export const noOpEvolutionHook: EvolutionHook = {
71
+ onPainDetected(_signal: PainSignal): void {},
72
+ onPrincipleCreated(_event: PrincipleCreatedEvent): void {},
73
+ onPrinciplePromoted(_event: PrinciplePromotedEvent): void {},
74
+ };
@@ -0,0 +1,203 @@
1
+ /**
2
+ * FileStorageAdapter — file-backed implementation of StorageAdapter.
3
+ *
4
+ * Wraps principle-tree-ledger functions with the async StorageAdapter
5
+ * contract. Uses withLockAsync for thread-safe mutateLedger with
6
+ * retry with exponential backoff for lock acquisition (5 retries).
7
+ * Write failures are logged via SystemLogger and re-thrown.
8
+ *
9
+ * Guarantees:
10
+ * - Atomic writes via atomicWriteFileSync (temp + rename)
11
+ * - Thread-safe concurrent access via file locks
12
+ * - Consistent read-after-write visibility
13
+ * - Write failures logged to SystemLogger and re-thrown
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import type { StorageAdapter } from './storage-adapter.js';
18
+ import type { HybridLedgerStore } from './principle-tree-ledger.js';
19
+ import { TREE_NAMESPACE } from './principle-tree-ledger.js';
20
+ import {
21
+ loadLedger as loadLedgerFromFile,
22
+ saveLedgerAsync,
23
+ } from './principle-tree-ledger.js';
24
+ import { withLockAsync, type LockOptions, LockAcquisitionError } from '../utils/file-lock.js';
25
+ import { atomicWriteFileSync } from '../utils/io.js';
26
+ import { SystemLogger } from './system-logger.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Configuration
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** Maximum retries for lock acquisition in mutateLedger. */
33
+ const MUTATE_RETRY_COUNT = 5;
34
+
35
+ /** Base delay in ms for exponential backoff between retries. */
36
+ const MUTATE_BACKOFF_BASE_MS = 50;
37
+
38
+ /** Maximum backoff delay in ms. */
39
+ const MUTATE_BACKOFF_MAX_MS = 500;
40
+
41
+ const PRINCIPLE_TRAINING_FILE = 'principle_training_state.json';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Internal helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Serialize the hybrid ledger store to JSON.
49
+ * Mirrors the unexported serializeLedger from principle-tree-ledger.ts.
50
+ */
51
+ function serializeStore(store: HybridLedgerStore): string {
52
+ return JSON.stringify(
53
+ {
54
+ ...store.trainingStore,
55
+ [TREE_NAMESPACE]: {
56
+ ...store.tree,
57
+ lastUpdated: new Date().toISOString(),
58
+ },
59
+ },
60
+ null,
61
+ 2,
62
+ );
63
+ }
64
+
65
+ /** Ensure the parent directory exists before writing. */
66
+ function ensureParentDir(filePath: string): void {
67
+ const dir = path.dirname(filePath);
68
+ if (!fs.existsSync(dir)) {
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ }
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // FileStorageAdapter
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * File-system backed storage adapter for the principle ledger.
79
+ *
80
+ * Delegates read/write operations to principle-tree-ledger while providing
81
+ * the async StorageAdapter interface. The mutateLedger method uses
82
+ * withLockAsync with exponential backoff retry for robust concurrent access.
83
+ */
84
+ export class FileStorageAdapter implements StorageAdapter {
85
+ private readonly stateDir: string;
86
+ private readonly workspaceDir: string | undefined;
87
+
88
+ constructor(stateDir: string, workspaceDir?: string) {
89
+ this.stateDir = stateDir;
90
+ this.workspaceDir = workspaceDir;
91
+ }
92
+
93
+ /** Resolve the ledger file path for this state directory. */
94
+ private get filePath(): string {
95
+ return path.join(this.stateDir, PRINCIPLE_TRAINING_FILE);
96
+ }
97
+
98
+ /**
99
+ * Load the current ledger state from the file system.
100
+ *
101
+ * Returns an empty store if no persisted state exists (first run).
102
+ * Uses the synchronous loadLedger from principle-tree-ledger which
103
+ * handles missing/corrupted files gracefully.
104
+ */
105
+ async loadLedger(): Promise<HybridLedgerStore> {
106
+ return loadLedgerFromFile(this.stateDir);
107
+ }
108
+
109
+ /**
110
+ * Persist the full ledger state atomically.
111
+ *
112
+ * Delegates to principle-tree-ledger's saveLedgerAsync which uses
113
+ * withLockAsync internally. Logs failures via SystemLogger.
114
+ */
115
+ async saveLedger(store: HybridLedgerStore): Promise<void> {
116
+ try {
117
+ await saveLedgerAsync(this.stateDir, store);
118
+ } catch (err) {
119
+ SystemLogger.log(
120
+ this.workspaceDir,
121
+ 'STORAGE_WRITE_FAILED',
122
+ `FileStorageAdapter.saveLedger failed: ${String(err)}`,
123
+ );
124
+ throw err;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Perform a read-modify-write cycle with automatic locking and retry.
130
+ *
131
+ * Uses withLockAsync to acquire a file lock, reads the current store,
132
+ * applies the mutate function, then writes the modified store atomically.
133
+ * On lock acquisition failure, retries up to MUTATE_RETRY_COUNT (5) times
134
+ * with exponential backoff + jitter to reduce contention.
135
+ *
136
+ * Write failures are logged to SystemLogger and re-thrown so callers
137
+ * can decide how to handle persistence errors.
138
+ */
139
+ async mutateLedger<T>(mutate: (store: HybridLedgerStore) => T | Promise<T>): Promise<T> {
140
+ let lastError: Error | undefined;
141
+
142
+ for (let attempt = 0; attempt < MUTATE_RETRY_COUNT; attempt++) {
143
+ try {
144
+ const lockOptions: LockOptions = {
145
+ maxRetries: 3,
146
+ baseRetryDelayMs: 10,
147
+ maxRetryDelayMs: 200,
148
+ lockStaleMs: 10_000,
149
+ };
150
+
151
+ const ledgerPath = this.filePath;
152
+
153
+ return await withLockAsync(ledgerPath, async () => {
154
+ const store = loadLedgerFromFile(this.stateDir);
155
+ const result = await mutate(store);
156
+
157
+ // Write directly — we already hold the lock, so we must NOT
158
+ // call saveLedger/saveLedgerAsync (they try to acquire the same lock).
159
+ try {
160
+ ensureParentDir(ledgerPath);
161
+ atomicWriteFileSync(ledgerPath, serializeStore(store));
162
+ } catch (writeErr) {
163
+ SystemLogger.log(
164
+ this.workspaceDir,
165
+ 'STORAGE_WRITE_FAILED',
166
+ `FileStorageAdapter.mutateLedger write failed: ${String(writeErr)}`,
167
+ );
168
+ throw writeErr;
169
+ }
170
+
171
+ return result;
172
+ }, lockOptions);
173
+ } catch (err) {
174
+ lastError = err as Error;
175
+
176
+ // Only retry on lock acquisition errors
177
+ if (err instanceof LockAcquisitionError && attempt < MUTATE_RETRY_COUNT - 1) {
178
+ const delay = Math.min(
179
+ MUTATE_BACKOFF_BASE_MS * Math.pow(2, attempt),
180
+ MUTATE_BACKOFF_MAX_MS,
181
+ );
182
+ // Add jitter (0-20%) to avoid thundering herd
183
+ const jitter = delay * 0.2 * Math.random();
184
+ const totalDelay = Math.floor(delay + jitter);
185
+
186
+ await new Promise((resolve) => setTimeout(resolve, totalDelay));
187
+ continue;
188
+ }
189
+
190
+ // Non-retryable error or exhausted retries
191
+ SystemLogger.log(
192
+ this.workspaceDir,
193
+ 'STORAGE_MUTATE_FAILED',
194
+ `FileStorageAdapter.mutateLedger failed after ${attempt + 1} attempts: ${String(err)}`,
195
+ );
196
+ throw err;
197
+ }
198
+ }
199
+
200
+ // Should not reach here, but satisfy the type checker
201
+ throw lastError ?? new Error('FileStorageAdapter.mutateLedger: unexpected state');
202
+ }
203
+ }