principles-disciple 1.52.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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/bootstrap-rules.ts +41 -4
- package/src/core/evolution-hook.ts +74 -0
- package/src/core/file-storage-adapter.ts +203 -0
- package/src/core/init.ts +29 -2
- package/src/core/nocturnal-trinity.ts +230 -0
- package/src/core/observability.ts +242 -0
- package/src/core/pain-signal-adapter.ts +42 -0
- package/src/core/pain-signal.ts +136 -0
- package/src/core/principle-injection.ts +208 -0
- package/src/core/principle-injector.ts +84 -0
- package/src/core/storage-adapter.ts +65 -0
- package/src/core/telemetry-event.ts +109 -0
- package/src/hooks/prompt.ts +18 -3
- package/src/service/evolution-worker.ts +52 -2
- package/tests/core/evolution-hook.test.ts +123 -0
- package/tests/core/file-storage-adapter.test.ts +285 -0
- package/tests/core/nocturnal-trinity.test.ts +236 -0
- package/tests/core/observability.test.ts +383 -0
- package/tests/core/pain-signal-adapter.test.ts +116 -0
- package/tests/core/pain-signal.test.ts +190 -0
- package/tests/core/principle-injection.test.ts +223 -0
- package/tests/core/principle-injector.test.ts +90 -0
- package/tests/core/storage-conformance.test.ts +429 -0
- package/tests/core/telemetry-event.test.ts +119 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Conformance Suite for StorageAdapter implementations.
|
|
3
|
+
*
|
|
4
|
+
* This suite accepts a factory function that creates a StorageAdapter instance
|
|
5
|
+
* and tests the following contract guarantees:
|
|
6
|
+
*
|
|
7
|
+
* 1. Atomic writes/reads: data written is data read back
|
|
8
|
+
* 2. Concurrent mutation with locks: overlapping mutateLedger calls serialize
|
|
9
|
+
* 3. Persistence across restarts: data survives adapter re-creation
|
|
10
|
+
* 4. Error handling: malformed state is handled gracefully
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { describeStorageConformance } from './storage-conformance.test.js';
|
|
14
|
+
* describeStorageConformance('FileStorageAdapter', () => new FileStorageAdapter(tmpDir));
|
|
15
|
+
*
|
|
16
|
+
* The factory receives a fresh temp directory for each test.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as os from 'os';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import type { StorageAdapter } from '../../src/core/storage-adapter.js';
|
|
23
|
+
import type { HybridLedgerStore } from '../../src/core/principle-tree-ledger.js';
|
|
24
|
+
import { safeRmDir } from '../test-utils.js';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function createTmpDir(): string {
|
|
31
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-storage-conformance-'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createEmptyStore(): HybridLedgerStore {
|
|
35
|
+
return {
|
|
36
|
+
trainingStore: {},
|
|
37
|
+
tree: {
|
|
38
|
+
principles: {},
|
|
39
|
+
rules: {},
|
|
40
|
+
implementations: {},
|
|
41
|
+
metrics: {},
|
|
42
|
+
lastUpdated: new Date(0).toISOString(),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createTestPrinciple(id: string, text?: string) {
|
|
48
|
+
return {
|
|
49
|
+
id,
|
|
50
|
+
version: 1,
|
|
51
|
+
text: text ?? `Test principle ${id}`,
|
|
52
|
+
triggerPattern: 'test',
|
|
53
|
+
action: 'verify',
|
|
54
|
+
status: 'candidate' as const,
|
|
55
|
+
priority: 'P1' as const,
|
|
56
|
+
scope: 'general',
|
|
57
|
+
evaluability: 'manual_only' as const,
|
|
58
|
+
valueScore: 0,
|
|
59
|
+
adherenceRate: 0,
|
|
60
|
+
painPreventedCount: 0,
|
|
61
|
+
derivedFromPainIds: [] as string[],
|
|
62
|
+
ruleIds: [] as string[],
|
|
63
|
+
conflictsWithPrincipleIds: [] as string[],
|
|
64
|
+
createdAt: '2026-04-17T00:00:00.000Z',
|
|
65
|
+
updatedAt: '2026-04-17T00:00:00.000Z',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Exported conformance suite
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export type StorageAdapterFactory = (stateDir: string) => StorageAdapter;
|
|
74
|
+
|
|
75
|
+
export function describeStorageConformance(
|
|
76
|
+
name: string,
|
|
77
|
+
factory: StorageAdapterFactory,
|
|
78
|
+
): void {
|
|
79
|
+
describe(`Storage Conformance: ${name}`, () => {
|
|
80
|
+
let tmpDir: string;
|
|
81
|
+
let adapter: StorageAdapter;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
tmpDir = createTmpDir();
|
|
85
|
+
adapter = factory(tmpDir);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
safeRmDir(tmpDir);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// -------------------------------------------------------------------------
|
|
93
|
+
// 1. Atomic writes/reads
|
|
94
|
+
// -------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
describe('atomic writes and reads', () => {
|
|
97
|
+
it('loadLedger returns empty store when no data exists', async () => {
|
|
98
|
+
const store = await adapter.loadLedger();
|
|
99
|
+
expect(store.trainingStore).toEqual({});
|
|
100
|
+
expect(store.tree.principles).toEqual({});
|
|
101
|
+
expect(store.tree.rules).toEqual({});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('saveLedger then loadLedger returns the same data', async () => {
|
|
105
|
+
const store = createEmptyStore();
|
|
106
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001', 'Write before delete');
|
|
107
|
+
|
|
108
|
+
await adapter.saveLedger(store);
|
|
109
|
+
const loaded = await adapter.loadLedger();
|
|
110
|
+
|
|
111
|
+
expect(loaded.tree.principles['P-001']).toBeDefined();
|
|
112
|
+
expect(loaded.tree.principles['P-001'].text).toBe('Write before delete');
|
|
113
|
+
expect(loaded.tree.principles['P-001'].id).toBe('P-001');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('saveLedger overwrites previous data atomically', async () => {
|
|
117
|
+
const store1 = createEmptyStore();
|
|
118
|
+
store1.tree.principles['P-001'] = createTestPrinciple('P-001', 'First write');
|
|
119
|
+
await adapter.saveLedger(store1);
|
|
120
|
+
|
|
121
|
+
const store2 = createEmptyStore();
|
|
122
|
+
store2.tree.principles['P-002'] = createTestPrinciple('P-002', 'Second write');
|
|
123
|
+
await adapter.saveLedger(store2);
|
|
124
|
+
|
|
125
|
+
const loaded = await adapter.loadLedger();
|
|
126
|
+
// Second write should have replaced the first
|
|
127
|
+
expect(loaded.tree.principles['P-001']).toBeUndefined();
|
|
128
|
+
expect(loaded.tree.principles['P-002']).toBeDefined();
|
|
129
|
+
expect(loaded.tree.principles['P-002'].text).toBe('Second write');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('preserves all store fields across save/load cycle', async () => {
|
|
133
|
+
const store = createEmptyStore();
|
|
134
|
+
store.trainingStore['tp-1'] = {
|
|
135
|
+
principleId: 'tp-1',
|
|
136
|
+
evaluability: 'weak_heuristic',
|
|
137
|
+
applicableOpportunityCount: 5,
|
|
138
|
+
observedViolationCount: 2,
|
|
139
|
+
complianceRate: 0.6,
|
|
140
|
+
violationTrend: -0.1,
|
|
141
|
+
generatedSampleCount: 3,
|
|
142
|
+
approvedSampleCount: 2,
|
|
143
|
+
includedTrainRunIds: ['run-1', 'run-2'],
|
|
144
|
+
deployedCheckpointIds: ['ckpt-1'],
|
|
145
|
+
internalizationStatus: 'in_training',
|
|
146
|
+
};
|
|
147
|
+
store.tree.principles['P-001'] = createTestPrinciple('P-001');
|
|
148
|
+
|
|
149
|
+
await adapter.saveLedger(store);
|
|
150
|
+
const loaded = await adapter.loadLedger();
|
|
151
|
+
|
|
152
|
+
// Verify training store
|
|
153
|
+
expect(loaded.trainingStore['tp-1'].evaluability).toBe('weak_heuristic');
|
|
154
|
+
expect(loaded.trainingStore['tp-1'].complianceRate).toBe(0.6);
|
|
155
|
+
expect(loaded.trainingStore['tp-1'].includedTrainRunIds).toEqual(['run-1', 'run-2']);
|
|
156
|
+
expect(loaded.trainingStore['tp-1'].deployedCheckpointIds).toEqual(['ckpt-1']);
|
|
157
|
+
|
|
158
|
+
// Verify tree principles
|
|
159
|
+
expect(loaded.tree.principles['P-001']).toBeDefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('mutateLedger reads and writes atomically', async () => {
|
|
163
|
+
await adapter.saveLedger(createEmptyStore());
|
|
164
|
+
|
|
165
|
+
const result = await adapter.mutateLedger((store) => {
|
|
166
|
+
store.tree.principles['P-MUT'] = createTestPrinciple('P-MUT', 'Mutated');
|
|
167
|
+
return Object.keys(store.tree.principles).length;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result).toBe(1);
|
|
171
|
+
|
|
172
|
+
const loaded = await adapter.loadLedger();
|
|
173
|
+
expect(loaded.tree.principles['P-MUT'].text).toBe('Mutated');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('mutateLedger returns the value from the mutate function', async () => {
|
|
177
|
+
await adapter.saveLedger(createEmptyStore());
|
|
178
|
+
|
|
179
|
+
const count = await adapter.mutateLedger((store) => {
|
|
180
|
+
return Object.keys(store.tree.principles).length;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(count).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// 2. Concurrent mutation with locks
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
describe('concurrent mutation with locks', () => {
|
|
192
|
+
it('serializes overlapping mutateLedger calls', async () => {
|
|
193
|
+
await adapter.saveLedger(createEmptyStore());
|
|
194
|
+
|
|
195
|
+
// Launch 5 concurrent mutations that each add a principle
|
|
196
|
+
const operations = Array.from({ length: 5 }, (_, i) =>
|
|
197
|
+
adapter.mutateLedger((store) => {
|
|
198
|
+
store.tree.principles[`P-CONC-${i}`] = createTestPrinciple(`P-CONC-${i}`, `Concurrent ${i}`);
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
await Promise.all(operations);
|
|
203
|
+
|
|
204
|
+
// All 5 should be present — no lost updates
|
|
205
|
+
const loaded = await adapter.loadLedger();
|
|
206
|
+
for (let i = 0; i < 5; i++) {
|
|
207
|
+
expect(loaded.tree.principles[`P-CONC-${i}`]).toBeDefined();
|
|
208
|
+
expect(loaded.tree.principles[`P-CONC-${i}`].text).toBe(`Concurrent ${i}`);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('concurrent mutateLedger calls with return values all resolve correctly', async () => {
|
|
213
|
+
await adapter.saveLedger(createEmptyStore());
|
|
214
|
+
|
|
215
|
+
// Seed with a principle
|
|
216
|
+
await adapter.mutateLedger((store) => {
|
|
217
|
+
store.tree.principles['P-SEED'] = createTestPrinciple('P-SEED');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Concurrent reads that return the count
|
|
221
|
+
const operations = Array.from({ length: 3 }, () =>
|
|
222
|
+
adapter.mutateLedger((store) => {
|
|
223
|
+
return Object.keys(store.tree.principles).length;
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const results = await Promise.all(operations);
|
|
228
|
+
|
|
229
|
+
// Each should see at least 1 (the seed) since mutations serialize
|
|
230
|
+
for (const count of results) {
|
|
231
|
+
expect(count).toBeGreaterThanOrEqual(1);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('no data loss under concurrent writes of different keys', async () => {
|
|
236
|
+
await adapter.saveLedger(createEmptyStore());
|
|
237
|
+
|
|
238
|
+
// One writes principles, another writes training store entries
|
|
239
|
+
const op1 = adapter.mutateLedger((store) => {
|
|
240
|
+
store.tree.principles['P-PRIN'] = createTestPrinciple('P-PRIN', 'Principle side');
|
|
241
|
+
});
|
|
242
|
+
const op2 = adapter.mutateLedger((store) => {
|
|
243
|
+
store.trainingStore['tp-train'] = {
|
|
244
|
+
principleId: 'tp-train',
|
|
245
|
+
evaluability: 'deterministic',
|
|
246
|
+
applicableOpportunityCount: 1,
|
|
247
|
+
observedViolationCount: 0,
|
|
248
|
+
complianceRate: 1.0,
|
|
249
|
+
violationTrend: 0,
|
|
250
|
+
generatedSampleCount: 0,
|
|
251
|
+
approvedSampleCount: 0,
|
|
252
|
+
includedTrainRunIds: [],
|
|
253
|
+
deployedCheckpointIds: [],
|
|
254
|
+
internalizationStatus: 'prompt_only',
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await Promise.all([op1, op2]);
|
|
259
|
+
|
|
260
|
+
const loaded = await adapter.loadLedger();
|
|
261
|
+
expect(loaded.tree.principles['P-PRIN']).toBeDefined();
|
|
262
|
+
expect(loaded.trainingStore['tp-train']).toBeDefined();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// -------------------------------------------------------------------------
|
|
267
|
+
// 3. Persistence across restarts
|
|
268
|
+
// -------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
describe('persistence across restarts', () => {
|
|
271
|
+
it('data persists when adapter is re-created on the same directory', async () => {
|
|
272
|
+
const store = createEmptyStore();
|
|
273
|
+
store.tree.principles['P-PERSIST'] = createTestPrinciple('P-PERSIST', 'Survives restart');
|
|
274
|
+
|
|
275
|
+
// Write with first adapter instance
|
|
276
|
+
await adapter.saveLedger(store);
|
|
277
|
+
|
|
278
|
+
// Simulate restart: create a new adapter pointing to the same dir
|
|
279
|
+
const restartedAdapter = factory(tmpDir);
|
|
280
|
+
const loaded = await restartedAdapter.loadLedger();
|
|
281
|
+
|
|
282
|
+
expect(loaded.tree.principles['P-PERSIST']).toBeDefined();
|
|
283
|
+
expect(loaded.tree.principles['P-PERSIST'].text).toBe('Survives restart');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('mutateLedger changes persist across adapter re-creation', async () => {
|
|
287
|
+
await adapter.saveLedger(createEmptyStore());
|
|
288
|
+
|
|
289
|
+
await adapter.mutateLedger((store) => {
|
|
290
|
+
store.tree.principles['P-MUT-PERSIST'] = createTestPrinciple('P-MUT-PERSIST', 'Mutate survives');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// New adapter instance
|
|
294
|
+
const restartedAdapter = factory(tmpDir);
|
|
295
|
+
const loaded = await restartedAdapter.loadLedger();
|
|
296
|
+
|
|
297
|
+
expect(loaded.tree.principles['P-MUT-PERSIST']).toBeDefined();
|
|
298
|
+
expect(loaded.tree.principles['P-MUT-PERSIST'].text).toBe('Mutate survives');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('training store data persists across adapter re-creation', async () => {
|
|
302
|
+
const store = createEmptyStore();
|
|
303
|
+
store.trainingStore['tp-persist'] = {
|
|
304
|
+
principleId: 'tp-persist',
|
|
305
|
+
evaluability: 'weak_heuristic',
|
|
306
|
+
applicableOpportunityCount: 10,
|
|
307
|
+
observedViolationCount: 3,
|
|
308
|
+
complianceRate: 0.7,
|
|
309
|
+
violationTrend: -0.2,
|
|
310
|
+
generatedSampleCount: 5,
|
|
311
|
+
approvedSampleCount: 4,
|
|
312
|
+
includedTrainRunIds: ['run-a'],
|
|
313
|
+
deployedCheckpointIds: [],
|
|
314
|
+
internalizationStatus: 'needs_training',
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
await adapter.saveLedger(store);
|
|
318
|
+
|
|
319
|
+
const restartedAdapter = factory(tmpDir);
|
|
320
|
+
const loaded = await restartedAdapter.loadLedger();
|
|
321
|
+
|
|
322
|
+
expect(loaded.trainingStore['tp-persist']).toBeDefined();
|
|
323
|
+
expect(loaded.trainingStore['tp-persist'].applicableOpportunityCount).toBe(10);
|
|
324
|
+
expect(loaded.trainingStore['tp-persist'].internalizationStatus).toBe('needs_training');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// -------------------------------------------------------------------------
|
|
329
|
+
// 4. Error handling
|
|
330
|
+
// -------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
describe('error handling', () => {
|
|
333
|
+
it('loadLedger handles corrupted JSON gracefully', async () => {
|
|
334
|
+
// Write invalid JSON to the ledger file
|
|
335
|
+
const filePath = path.join(tmpDir, 'principle_training_state.json');
|
|
336
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
337
|
+
fs.writeFileSync(filePath, '{ invalid json !!!', 'utf8');
|
|
338
|
+
|
|
339
|
+
// Should not throw — should return empty store
|
|
340
|
+
const store = await adapter.loadLedger();
|
|
341
|
+
expect(store.trainingStore).toEqual({});
|
|
342
|
+
expect(store.tree.principles).toEqual({});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('loadLedger handles empty file', async () => {
|
|
346
|
+
const filePath = path.join(tmpDir, 'principle_training_state.json');
|
|
347
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
348
|
+
fs.writeFileSync(filePath, '', 'utf8');
|
|
349
|
+
|
|
350
|
+
const store = await adapter.loadLedger();
|
|
351
|
+
expect(store.trainingStore).toEqual({});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('loadLedger handles file with null content', async () => {
|
|
355
|
+
const filePath = path.join(tmpDir, 'principle_training_state.json');
|
|
356
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
357
|
+
fs.writeFileSync(filePath, 'null', 'utf8');
|
|
358
|
+
|
|
359
|
+
const store = await adapter.loadLedger();
|
|
360
|
+
expect(store.trainingStore).toEqual({});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('saveLedger throws on write errors (read-only dir)', async () => {
|
|
364
|
+
// Create a scenario where mkdir would succeed but write fails:
|
|
365
|
+
// make the parent path a regular file so attempting to create nested dirs fails with ENOTDIR
|
|
366
|
+
const nestedParent = path.join(tmpDir, 'nonexistent');
|
|
367
|
+
fs.writeFileSync(nestedParent, 'blocker', 'utf8');
|
|
368
|
+
const readOnlyDir = path.join(nestedParent, 'nested');
|
|
369
|
+
const roAdapter = factory(readOnlyDir);
|
|
370
|
+
|
|
371
|
+
const store = createEmptyStore();
|
|
372
|
+
expect.assertions(1);
|
|
373
|
+
await expect(roAdapter.saveLedger(store)).rejects.toThrow(Error);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('mutateLedger propagates errors from the mutate function', async () => {
|
|
377
|
+
await adapter.saveLedger(createEmptyStore());
|
|
378
|
+
|
|
379
|
+
await expect(
|
|
380
|
+
adapter.mutateLedger(() => {
|
|
381
|
+
throw new Error('Intentional test error');
|
|
382
|
+
}),
|
|
383
|
+
).rejects.toThrow('Intentional test error');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('async mutateLedger propagates async errors', async () => {
|
|
387
|
+
await adapter.saveLedger(createEmptyStore());
|
|
388
|
+
|
|
389
|
+
await expect(
|
|
390
|
+
adapter.mutateLedger(async () => {
|
|
391
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
392
|
+
throw new Error('Async intentional error');
|
|
393
|
+
}),
|
|
394
|
+
).rejects.toThrow('Async intentional error');
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// -------------------------------------------------------------------------
|
|
399
|
+
// 5. Async support
|
|
400
|
+
// -------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
describe('async mutate support', () => {
|
|
403
|
+
it('supports async mutate functions', async () => {
|
|
404
|
+
await adapter.saveLedger(createEmptyStore());
|
|
405
|
+
|
|
406
|
+
const result = await adapter.mutateLedger(async (store) => {
|
|
407
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
408
|
+
store.tree.principles['P-ASYNC'] = createTestPrinciple('P-ASYNC', 'Async mutation');
|
|
409
|
+
return 'async-result';
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
expect(result).toBe('async-result');
|
|
413
|
+
const loaded = await adapter.loadLedger();
|
|
414
|
+
expect(loaded.tree.principles['P-ASYNC']).toBeDefined();
|
|
415
|
+
expect(loaded.tree.principles['P-ASYNC'].text).toBe('Async mutation');
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Run conformance suite against FileStorageAdapter
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
import { FileStorageAdapter } from '../../src/core/file-storage-adapter.js';
|
|
426
|
+
|
|
427
|
+
describeStorageConformance('FileStorageAdapter', (stateDir) => {
|
|
428
|
+
return new FileStorageAdapter(stateDir);
|
|
429
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TelemetryEventSchema, validateTelemetryEvent, type TelemetryEvent } from '../../src/core/telemetry-event.js';
|
|
3
|
+
import { Value } from '@sinclair/typebox/value';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function validEvent(overrides: Partial<TelemetryEvent> = {}): TelemetryEvent {
|
|
10
|
+
return {
|
|
11
|
+
eventType: 'pain_detected',
|
|
12
|
+
traceId: 'trace-001',
|
|
13
|
+
timestamp: '2026-04-17T00:00:00.000Z',
|
|
14
|
+
sessionId: 'session-001',
|
|
15
|
+
payload: {},
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Tests
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
describe('TelemetryEventSchema', () => {
|
|
25
|
+
it('accepts a valid pain_detected event', () => {
|
|
26
|
+
expect(Value.Check(TelemetryEventSchema, validEvent())).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('accepts a valid principle_candidate_created event', () => {
|
|
30
|
+
expect(Value.Check(TelemetryEventSchema, validEvent({ eventType: 'principle_candidate_created' }))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('accepts a valid principle_promoted event', () => {
|
|
34
|
+
expect(Value.Check(TelemetryEventSchema, validEvent({ eventType: 'principle_promoted' }))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('accepts an event with optional agentId', () => {
|
|
38
|
+
expect(Value.Check(TelemetryEventSchema, validEvent({ agentId: 'main' }))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts an event with payload data', () => {
|
|
42
|
+
expect(Value.Check(TelemetryEventSchema, validEvent({ payload: { toolName: 'edit_file', error: 'not found' } }))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects an event with invalid eventType', () => {
|
|
46
|
+
expect(Value.Check(TelemetryEventSchema, validEvent({ eventType: 'invalid_event' as TelemetryEvent['eventType'] }))).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects an event missing traceId', () => {
|
|
50
|
+
expect(Value.Check(TelemetryEventSchema, { ...validEvent(), traceId: undefined })).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects an event missing timestamp', () => {
|
|
54
|
+
expect(Value.Check(TelemetryEventSchema, { ...validEvent(), timestamp: undefined })).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects an event missing sessionId', () => {
|
|
58
|
+
expect(Value.Check(TelemetryEventSchema, { ...validEvent(), sessionId: undefined })).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects an event missing eventType', () => {
|
|
62
|
+
expect(Value.Check(TelemetryEventSchema, { ...validEvent(), eventType: undefined })).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects an event missing payload', () => {
|
|
66
|
+
expect(Value.Check(TelemetryEventSchema, { ...validEvent(), payload: undefined })).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects a non-object input', () => {
|
|
70
|
+
expect(Value.Check(TelemetryEventSchema, 'not an object')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('validateTelemetryEvent', () => {
|
|
75
|
+
it('returns valid:true for a valid event', () => {
|
|
76
|
+
const result = validateTelemetryEvent(validEvent());
|
|
77
|
+
expect(result.valid).toBe(true);
|
|
78
|
+
expect(result.event).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns the typed event when valid', () => {
|
|
82
|
+
const result = validateTelemetryEvent(validEvent({ traceId: 'test-123' }));
|
|
83
|
+
expect(result.valid).toBe(true);
|
|
84
|
+
expect(result.event?.traceId).toBe('test-123');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns valid:false for non-object input', () => {
|
|
88
|
+
const result = validateTelemetryEvent('string');
|
|
89
|
+
expect(result.valid).toBe(false);
|
|
90
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns valid:false for null input', () => {
|
|
94
|
+
const result = validateTelemetryEvent(null);
|
|
95
|
+
expect(result.valid).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns valid:false for array input', () => {
|
|
99
|
+
const result = validateTelemetryEvent([]);
|
|
100
|
+
expect(result.valid).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns errors for missing required fields', () => {
|
|
104
|
+
const result = validateTelemetryEvent({});
|
|
105
|
+
expect(result.valid).toBe(false);
|
|
106
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns errors for invalid eventType', () => {
|
|
110
|
+
const result = validateTelemetryEvent({ ...validEvent(), eventType: 'not_a_real_event' });
|
|
111
|
+
expect(result.valid).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('accepts event without agentId (optional field)', () => {
|
|
115
|
+
const result = validateTelemetryEvent(validEvent());
|
|
116
|
+
expect(result.valid).toBe(true);
|
|
117
|
+
expect(result.event?.agentId).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|