principles-disciple 1.106.0 → 1.108.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 (63) hide show
  1. package/openclaw.plugin.json +1 -1
  2. package/package.json +2 -2
  3. package/src/core/init.ts +3 -1
  4. package/src/core/workspace-dir-validation.ts +3 -3
  5. package/tests/core-anti-growth.test.ts +0 -13
  6. package/tests/hooks/prompt-characterization.test.ts +1 -11
  7. package/tests/hooks/prompt-diet.test.ts +3 -11
  8. package/tests/hooks/prompt-size-guard.test.ts +0 -10
  9. package/tests/hooks/runtime-v2-prompt-activation.test.ts +0 -10
  10. package/tests/index.test.ts +1 -1
  11. package/tests/runtime-v2-discovery-guard.test.ts +1 -2
  12. package/vitest.config.ts +2 -3
  13. package/vitest.unit.config.ts +12 -0
  14. package/src/core/evolution-hook.ts +0 -74
  15. package/src/core/file-storage-adapter.ts +0 -203
  16. package/src/core/merge-gate-audit.ts +0 -314
  17. package/src/core/pain-context-extractor.ts +0 -306
  18. package/src/core/pain-lifecycle.ts +0 -38
  19. package/src/core/pain-signal-adapter.ts +0 -42
  20. package/src/core/pain-signal.ts +0 -22
  21. package/src/core/principle-injector.ts +0 -84
  22. package/src/core/principle-tree-migration.ts +0 -196
  23. package/src/core/storage-adapter.ts +0 -65
  24. package/src/core/telemetry-event.ts +0 -109
  25. package/src/core/training-program.ts +0 -632
  26. package/src/core/workspace-dir-service.ts +0 -119
  27. package/src/hooks/lifecycle-routing.ts +0 -125
  28. package/src/service/event-log-auditor.ts +0 -284
  29. package/src/service/evolution-queue-lock.ts +0 -47
  30. package/src/service/failure-classifier.ts +0 -79
  31. package/src/service/internalization-trigger-adapter.ts +0 -302
  32. package/src/service/monitoring-query-service.ts +0 -67
  33. package/src/service/subagent-workflow/index.ts +0 -17
  34. package/src/tools/critique-prompt.ts +0 -1
  35. package/src/tools/model-index.ts +0 -1
  36. package/src/types/event-payload.ts +0 -16
  37. package/src/utils/glob-match.ts +0 -50
  38. package/src/utils/nlp.ts +0 -25
  39. package/src/utils/plugin-logger.ts +0 -97
  40. package/src/utils/subagent-probe.ts +0 -81
  41. package/tests/core/evolution-hook.test.ts +0 -123
  42. package/tests/core/file-storage-adapter.test.ts +0 -285
  43. package/tests/core/merge-gate-audit.test.ts +0 -117
  44. package/tests/core/pain-context-extractor.test.ts +0 -279
  45. package/tests/core/pain-lifecycle.test.ts +0 -38
  46. package/tests/core/pain-signal-adapter.test.ts +0 -116
  47. package/tests/core/pain-signal.test.ts +0 -190
  48. package/tests/core/principle-injector.test.ts +0 -90
  49. package/tests/core/principle-tree-migration.test.ts +0 -77
  50. package/tests/core/storage-conformance.test.ts +0 -429
  51. package/tests/core/telemetry-event.test.ts +0 -119
  52. package/tests/core/training-program.test.ts +0 -472
  53. package/tests/core/workspace-dir-service.test.ts +0 -68
  54. package/tests/core/workspace-dir-validation.test.ts +0 -143
  55. package/tests/integration/internalization-trigger-guard.test.ts +0 -69
  56. package/tests/integration/pain-lifecycle-e2e.test.ts +0 -75
  57. package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +0 -209
  58. package/tests/service/failure-classifier.test.ts +0 -171
  59. package/tests/service/internalization-trigger-adapter.test.ts +0 -251
  60. package/tests/service/monitoring-query-service.test.ts +0 -67
  61. package/tests/utils/nlp.test.ts +0 -35
  62. package/tests/utils/plugin-logger.test.ts +0 -156
  63. package/tests/utils/subagent-probe.test.ts +0 -79
@@ -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.106.0",
5
+ "version": "1.108.0",
6
6
  "activation": {
7
7
  "onCapabilities": [
8
8
  "hook"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.106.0",
3
+ "version": "1.108.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -34,7 +34,7 @@
34
34
  "build:bundle": "node esbuild.config.js",
35
35
  "build:production": "node esbuild.config.js --production && node scripts/verify-build.mjs",
36
36
  "test": "vitest run",
37
- "test:unit": "vitest run tests/core tests/service tests/hooks tests/commands tests/utils tests/scripts --exclude tests/commands/evolver.test.ts",
37
+ "test:unit": "vitest run --config vitest.unit.config.ts tests/core tests/service tests/hooks tests/commands tests/utils tests/scripts --exclude tests/commands/evolver.test.ts",
38
38
  "test:integration": "vitest run tests/integration/",
39
39
  "test:coverage": "vitest run --coverage",
40
40
  "test:all": "vitest run",
package/src/core/init.ts CHANGED
@@ -225,7 +225,9 @@ export function ensureCorePrinciples(stateDir: string, logger: PluginLogger): bo
225
225
  addPrincipleToLedger(stateDir, ledgerPrinciple);
226
226
  }
227
227
 
228
- logger.info(`[PD] Initialized ${CORE_THINKING_MODELS.length} core thinking models: T-01 through T-10`);
228
+ const firstId = CORE_THINKING_MODELS[0]?.id ?? 'T-01';
229
+ const lastId = CORE_THINKING_MODELS[CORE_THINKING_MODELS.length - 1]?.id ?? 'T-10';
230
+ logger.info(`[PD] Initialized ${CORE_THINKING_MODELS.length} core thinking models: ${firstId} through ${lastId}`);
229
231
  return true;
230
232
  } catch (err) {
231
233
  logger.error(`[PD] Failed to initialize core principles: ${String(err)}`);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * WorkspaceDir Validation Utilities
3
3
  *
4
- * This module only validates candidate workspace directories and delegates
5
- * actual resolution policy to workspace-dir-service.ts.
4
+ * This module only validates candidate workspace directories. Runtime hook
5
+ * resolution policy lives at the I/O boundary in utils/workspace-resolver.ts.
6
6
  */
7
7
 
8
8
  import * as os from 'os';
@@ -49,4 +49,4 @@ export function validateWorkspaceDir(dir: string | undefined): string | null {
49
49
  }
50
50
 
51
51
  return null;
52
- }
52
+ }
@@ -25,10 +25,8 @@ describe('PRI-212 plugin core anti-growth guard', () => {
25
25
  const ZERO_IMPORT_CANDIDATES = [
26
26
  'trajectory-types.ts',
27
27
  'profile.ts',
28
- 'pain-signal.ts',
29
28
  'pd-task-types.ts',
30
29
  'evolution-types.ts',
31
- 'telemetry-event.ts',
32
30
  'empathy-types.ts',
33
31
  'correction-types.ts',
34
32
  'principle-injection.ts',
@@ -39,12 +37,10 @@ describe('PRI-212 plugin core anti-growth guard', () => {
39
37
  const PLUGIN_IO_FILES = [
40
38
  // Thin adapter candidates
41
39
  'local-worker-routing.ts',
42
- 'principle-tree-migration.ts',
43
40
  'principle-internalization/principle-lifecycle-service.ts',
44
41
  'principle-tree-ledger-adapter.ts',
45
42
  'principle-compiler/ledger-registrar.ts',
46
43
  'principle-compiler/code-validator.ts',
47
- 'principle-injector.ts',
48
44
  'pd-task-service.ts',
49
45
  'principle-internalization/lifecycle-read-model.ts',
50
46
  'principle-internalization/filesystem-lifecycle-datasource.ts',
@@ -65,14 +61,10 @@ describe('PRI-212 plugin core anti-growth guard', () => {
65
61
  'pain-diagnostic-gate.ts',
66
62
  'hygiene/tracker.ts',
67
63
  'schema/migrations/002-init-central.ts',
68
- 'workspace-dir-service.ts',
69
64
  'paths.ts',
70
65
  'schema/migrations/004-add-thinking-and-gfi.ts',
71
- 'evolution-hook.ts',
72
- 'storage-adapter.ts',
73
66
  'schema/migrations/003-init-workflow.ts',
74
67
  'workspace-dir-validation.ts',
75
- 'pain-signal-adapter.ts',
76
68
  'rule-implementation-runtime.ts',
77
69
  'detection-service.ts',
78
70
  'schema/migrations/index.ts',
@@ -89,10 +81,8 @@ describe('PRI-212 plugin core anti-growth guard', () => {
89
81
  'model-training-registry.ts',
90
82
  'focus-history.ts',
91
83
  'model-deployment-registry.ts',
92
- 'training-program.ts',
93
84
  'replay-engine.ts',
94
85
  'external-training-contract.ts',
95
- 'merge-gate-audit.ts',
96
86
  'shadow-observation-registry.ts',
97
87
  'control-ui-db.ts',
98
88
  'thinking-models.ts',
@@ -100,11 +90,9 @@ describe('PRI-212 plugin core anti-growth guard', () => {
100
90
  'correction-cue-learner.ts',
101
91
  'principle-compiler/compiler.ts',
102
92
  'pain.ts',
103
- 'pain-context-extractor.ts',
104
93
  'config.ts',
105
94
  'code-implementation-storage.ts',
106
95
  'observability.ts',
107
- 'file-storage-adapter.ts',
108
96
  'workflow-funnel-loader.ts',
109
97
  'dictionary.ts',
110
98
  'thinking-os-parser.ts',
@@ -116,7 +104,6 @@ describe('PRI-212 plugin core anti-growth guard', () => {
116
104
  'pd-task-store.ts',
117
105
  'evolution-migration.ts',
118
106
  'empathy-keyword-matcher.ts',
119
- 'pain-lifecycle.ts',
120
107
  'session-tracker.ts',
121
108
  'principle-tree-ledger.ts',
122
109
  'evolution-logger.ts',
@@ -136,16 +136,6 @@ vi.mock('../../src/core/focus-history.js', () => ({
136
136
  safeReadCurrentFocus: vi.fn().mockReturnValue({ content: '', recovered: false, validationErrors: [] }),
137
137
  }));
138
138
 
139
- vi.mock('../../src/service/subagent-workflow/index.js', () => ({
140
- EmpathyObserverWorkflowManager: vi.fn(),
141
- empathyObserverWorkflowSpec: {},
142
- isExpectedSubagentError: vi.fn().mockReturnValue(false),
143
- }));
144
-
145
- vi.mock('../../src/utils/subagent-probe.js', () => ({
146
- isSubagentRuntimeAvailable: vi.fn().mockReturnValue(false),
147
- }));
148
-
149
139
  vi.mock('../../src/core/local-worker-routing.js', () => ({
150
140
  classifyTask: vi.fn().mockReturnValue({
151
141
  decision: 'stay_main',
@@ -483,4 +473,4 @@ describe('Size guard: never exceeds 9000 chars', () => {
483
473
  await expect(handleBeforePromptBuild(makeMinimalEvent(), makeCtx({ trigger: 'user' })))
484
474
  .resolves.toBeDefined();
485
475
  });
486
- });
476
+ });
@@ -20,10 +20,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
20
20
  beforeEach(() => {
21
21
  vi.clearAllMocks();
22
22
  process.env.PD_LEGACY_PROMPT_DIAGNOSTICIAN_ENABLED = 'true';
23
+ process.env.PD_EMPATHY_API_KEY_ENV = 'PD_TEST_DISABLED_EMPATHY_API_KEY';
23
24
  });
24
25
 
25
26
  afterEach(() => {
26
27
  process.env.PD_LEGACY_PROMPT_DIAGNOSTICIAN_ENABLED = '';
28
+ delete process.env.PD_EMPATHY_API_KEY_ENV;
27
29
  });
28
30
 
29
31
  const mockGetPendingDiagnosticianTasks = vi.fn<(stateDir: string) => unknown[]>();
@@ -123,16 +125,6 @@ vi.mock('../../src/core/focus-history.js', () => ({
123
125
  safeReadCurrentFocus: vi.fn().mockReturnValue({ content: '', recovered: false, validationErrors: [] }),
124
126
  }));
125
127
 
126
- vi.mock('../../src/service/subagent-workflow/index.js', () => ({
127
- EmpathyObserverWorkflowManager: vi.fn(),
128
- empathyObserverWorkflowSpec: {},
129
- isExpectedSubagentError: vi.fn().mockReturnValue(false),
130
- }));
131
-
132
- vi.mock('../../src/utils/subagent-probe.js', () => ({
133
- isSubagentRuntimeAvailable: vi.fn().mockReturnValue(false),
134
- }));
135
-
136
128
  vi.mock('../../src/core/local-worker-routing.js', () => ({
137
129
  classifyTask: vi.fn().mockReturnValue({
138
130
  decision: 'stay_main',
@@ -304,7 +296,7 @@ describe('PRI-291 Prompt Diet: MVP sections preserved', () => {
304
296
  resolve: (key: string) => `/fake/${key}`,
305
297
  trajectory: { recordSession: vi.fn(), recordUserTurn: vi.fn(), recordPainEvent: vi.fn() },
306
298
  config: { get: vi.fn().mockImplementation((k: string) => {
307
- if (k === 'thresholds.pain_trigger') return 40;
299
+ if (k === 'thresholds.pain_trigger') return 100;
308
300
  if (k === 'severity_thresholds.high') return 70;
309
301
  if (k === 'language') return 'en';
310
302
  return undefined;
@@ -118,16 +118,6 @@ vi.mock('../../src/core/focus-history.js', () => ({
118
118
  safeReadCurrentFocus: vi.fn().mockReturnValue({ content: '', recovered: false, validationErrors: [] }),
119
119
  }));
120
120
 
121
- vi.mock('../../src/service/subagent-workflow/index.js', () => ({
122
- EmpathyObserverWorkflowManager: vi.fn(),
123
- empathyObserverWorkflowSpec: {},
124
- isExpectedSubagentError: vi.fn().mockReturnValue(false),
125
- }));
126
-
127
- vi.mock('../../src/utils/subagent-probe.js', () => ({
128
- isSubagentRuntimeAvailable: vi.fn().mockReturnValue(false),
129
- }));
130
-
131
121
  vi.mock('../../src/core/local-worker-routing.js', () => ({
132
122
  classifyTask: vi.fn().mockReturnValue({
133
123
  decision: 'stay_main',
@@ -145,16 +145,6 @@ vi.mock('../../src/core/focus-history.js', () => ({
145
145
  safeReadCurrentFocus: vi.fn().mockReturnValue({ content: '', recovered: false, validationErrors: [] }),
146
146
  }));
147
147
 
148
- vi.mock('../../src/service/subagent-workflow/index.js', () => ({
149
- EmpathyObserverWorkflowManager: vi.fn(),
150
- empathyObserverWorkflowSpec: {},
151
- isExpectedSubagentError: vi.fn().mockReturnValue(false),
152
- }));
153
-
154
- vi.mock('../../src/utils/subagent-probe.js', () => ({
155
- isSubagentRuntimeAvailable: vi.fn().mockReturnValue(false),
156
- }));
157
-
158
148
  vi.mock('../../src/core/local-worker-routing.js', () => ({
159
149
  classifyTask: vi.fn().mockReturnValue({
160
150
  decision: 'stay_main',
@@ -89,7 +89,7 @@ describe('Command Registration', () => {
89
89
  expect(pdPain).toBeDefined();
90
90
 
91
91
  const ctx: PluginCommandContext = {
92
- sessionId: 'session-123',
92
+ sessionId: '',
93
93
  sessionKey: 'sk-123',
94
94
  workspaceDir: '/mock/workspace',
95
95
  args: 'test pain reason',
@@ -17,7 +17,6 @@ describe('PRI-228: Runtime V2 discovery guard', () => {
17
17
  const guardedFiles = [
18
18
  { name: 'workspace-resolver.ts', path: path.join(PLUGIN_SRC, 'utils', 'workspace-resolver.ts') },
19
19
  { name: 'workspace-context.ts', path: path.join(PLUGIN_SRC, 'core', 'workspace-context.ts') },
20
- { name: 'workspace-dir-service.ts', path: path.join(PLUGIN_SRC, 'core', 'workspace-dir-service.ts') },
21
20
  { name: 'path-resolver.ts', path: path.join(PLUGIN_SRC, 'core', 'path-resolver.ts') },
22
21
  ];
23
22
 
@@ -151,4 +150,4 @@ describe('PRI-228: Runtime V2 discovery guard', () => {
151
150
  expect(content).toContain('resolveWorkspaceDirForRuntimeV2(');
152
151
  });
153
152
  });
154
- });
153
+ });
package/vitest.config.ts CHANGED
@@ -19,7 +19,7 @@ import { defineConfig } from 'vitest/config';
19
19
 
20
20
  // Integration tests: use real SQLite database
21
21
  // These tests require better-sqlite3 to be compiled
22
- const integrationTests = [
22
+ export const integrationTests = [
23
23
  // Core DB tests
24
24
  'tests/core/control-ui-db.test.ts',
25
25
  'tests/core/evolution-logger.test.ts',
@@ -31,7 +31,6 @@ const integrationTests = [
31
31
  'tests/service/keyword-optimization-service.test.ts',
32
32
  // Hook tests with DB dependencies
33
33
  'tests/hooks/subagent.test.ts',
34
- 'tests/hooks/gate-pipeline-integration.test.ts',
35
34
  'tests/hooks/gate-rule-host-pipeline.test.ts',
36
35
  // Script tests with DB
37
36
  'tests/scripts/validate-live-path.test.ts',
@@ -59,4 +58,4 @@ export default defineConfig({
59
58
  },
60
59
  },
61
60
  },
62
- });
61
+ });
@@ -0,0 +1,12 @@
1
+ import { defineConfig, mergeConfig } from 'vitest/config';
2
+ import baseConfig, { integrationTests } from './vitest.config.js';
3
+
4
+ export default mergeConfig(baseConfig, defineConfig({
5
+ test: {
6
+ exclude: [
7
+ '**/node_modules/**',
8
+ '**/dist/**',
9
+ ...integrationTests,
10
+ ],
11
+ },
12
+ }));
@@ -1,74 +0,0 @@
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
- };
@@ -1,203 +0,0 @@
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
- }