principles-disciple 1.14.0 → 1.16.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 +4 -2
- package/scripts/bootstrap-rules.mjs +66 -0
- package/scripts/validate-live-path.ts +356 -0
- package/src/core/bootstrap-rules.ts +177 -0
- package/src/core/principle-tree-migration.ts +196 -0
- package/src/service/evolution-worker.ts +81 -61
- package/src/service/monitoring-query-service.ts +277 -0
- package/src/service/nocturnal-service.ts +9 -1
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +10 -2
- package/tests/core/bootstrap-rules.test.ts +582 -0
- package/tests/core/principle-tree-migration.test.ts +77 -0
- package/tests/scripts/validate-live-path.test.ts +286 -0
- package/tests/service/evolution-worker.nocturnal.test.ts +208 -0
- package/tests/service/monitoring-query-service.test.ts +113 -0
- package/tests/service/nocturnal-runtime-hardening.test.ts +85 -0
- package/ui/src/charts.tsx +4 -1
- package/ui/src/pages/ThinkingModelsPage.tsx +9 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { needsMigration } from '../../src/core/principle-tree-migration.js';
|
|
6
|
+
import { safeRmDir } from '../test-utils.js';
|
|
7
|
+
|
|
8
|
+
function writeLedger(stateDir: string, payload: unknown): void {
|
|
9
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
10
|
+
fs.writeFileSync(
|
|
11
|
+
path.join(stateDir, 'principle_training_state.json'),
|
|
12
|
+
JSON.stringify(payload, null, 2),
|
|
13
|
+
'utf8'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('principle-tree-migration', () => {
|
|
18
|
+
const tempDirs: string[] = [];
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
while (tempDirs.length > 0) {
|
|
22
|
+
const dir = tempDirs.pop();
|
|
23
|
+
if (dir) safeRmDir(dir);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('requires migration when trainingStore and tree.principles counts match but ids differ', () => {
|
|
28
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-migration-'));
|
|
29
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
30
|
+
tempDirs.push(workspaceDir);
|
|
31
|
+
|
|
32
|
+
writeLedger(stateDir, {
|
|
33
|
+
P_001: {
|
|
34
|
+
principleId: 'P_001',
|
|
35
|
+
evaluability: 'manual_only',
|
|
36
|
+
applicableOpportunityCount: 0,
|
|
37
|
+
observedViolationCount: 0,
|
|
38
|
+
complianceRate: 0,
|
|
39
|
+
violationTrend: 0,
|
|
40
|
+
generatedSampleCount: 0,
|
|
41
|
+
approvedSampleCount: 0,
|
|
42
|
+
includedTrainRunIds: [],
|
|
43
|
+
deployedCheckpointIds: [],
|
|
44
|
+
internalizationStatus: 'prompt_only',
|
|
45
|
+
},
|
|
46
|
+
_tree: {
|
|
47
|
+
principles: {
|
|
48
|
+
P_999: {
|
|
49
|
+
id: 'P_999',
|
|
50
|
+
version: 1,
|
|
51
|
+
text: 'Other principle',
|
|
52
|
+
triggerPattern: '',
|
|
53
|
+
action: '',
|
|
54
|
+
status: 'candidate',
|
|
55
|
+
priority: 'P1',
|
|
56
|
+
scope: 'general',
|
|
57
|
+
evaluability: 'manual_only',
|
|
58
|
+
valueScore: 0,
|
|
59
|
+
adherenceRate: 0,
|
|
60
|
+
painPreventedCount: 0,
|
|
61
|
+
derivedFromPainIds: [],
|
|
62
|
+
ruleIds: [],
|
|
63
|
+
conflictsWithPrincipleIds: [],
|
|
64
|
+
createdAt: '2026-04-10T00:00:00.000Z',
|
|
65
|
+
updatedAt: '2026-04-10T00:00:00.000Z',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
rules: {},
|
|
69
|
+
implementations: {},
|
|
70
|
+
metrics: {},
|
|
71
|
+
lastUpdated: '2026-04-10T00:00:00.000Z',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(needsMigration(stateDir)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate Live Path Script Tests (Phase 18)
|
|
3
|
+
*
|
|
4
|
+
* TDD test suite for live path validation script.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
10
|
+
import { safeRmDir } from '../test-utils.js';
|
|
11
|
+
import {
|
|
12
|
+
loadLedger,
|
|
13
|
+
saveLedger,
|
|
14
|
+
createRule,
|
|
15
|
+
updatePrinciple,
|
|
16
|
+
type HybridLedgerStore,
|
|
17
|
+
type LedgerPrinciple,
|
|
18
|
+
type LedgerRule,
|
|
19
|
+
} from '../../src/core/principle-tree-ledger.js';
|
|
20
|
+
import type { LegacyPrincipleTrainingStore, LegacyPrincipleTrainingState } from '../../src/core/principle-tree-ledger.js';
|
|
21
|
+
|
|
22
|
+
// Script path
|
|
23
|
+
const SCRIPT_PATH = path.join(__dirname, '../../scripts/validate-live-path.ts');
|
|
24
|
+
|
|
25
|
+
describe('validate-live-path script', () => {
|
|
26
|
+
let tempDir: string;
|
|
27
|
+
let stateDir: string;
|
|
28
|
+
let queuePath: string;
|
|
29
|
+
let dbPath: string;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
tempDir = fs.mkdtempSync(path.join(process.env.TMP || '/tmp', 'pd-validate-'));
|
|
33
|
+
stateDir = path.join(tempDir, '.state');
|
|
34
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
queuePath = path.join(stateDir, 'EVOLUTION_QUEUE');
|
|
37
|
+
dbPath = path.join(stateDir, 'subagent_workflows.db');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
safeRmDir(tempDir);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Helper: Create a minimal ledger principle
|
|
45
|
+
function createLedgerPrinciple(principleId: string, overrides: Partial<LedgerPrinciple> = {}): LedgerPrinciple {
|
|
46
|
+
return {
|
|
47
|
+
id: principleId,
|
|
48
|
+
version: 1,
|
|
49
|
+
text: `Test principle ${principleId}`,
|
|
50
|
+
triggerPattern: 'test',
|
|
51
|
+
action: 'test action',
|
|
52
|
+
status: 'active',
|
|
53
|
+
priority: 'P1',
|
|
54
|
+
scope: 'general',
|
|
55
|
+
evaluability: 'deterministic',
|
|
56
|
+
valueScore: 0,
|
|
57
|
+
adherenceRate: 0,
|
|
58
|
+
painPreventedCount: 0,
|
|
59
|
+
derivedFromPainIds: [],
|
|
60
|
+
ruleIds: [],
|
|
61
|
+
conflictsWithPrincipleIds: [],
|
|
62
|
+
createdAt: '2026-04-10T00:00:00.000Z',
|
|
63
|
+
updatedAt: '2026-04-10T00:00:00.000Z',
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Helper: Setup ledger with bootstrapped rules
|
|
69
|
+
function setupBootstrapLedger(): void {
|
|
70
|
+
const trainingStore: LegacyPrincipleTrainingStore = {
|
|
71
|
+
'P_test_001': {
|
|
72
|
+
principleId: 'P_test_001',
|
|
73
|
+
evaluability: 'deterministic',
|
|
74
|
+
applicableOpportunityCount: 10,
|
|
75
|
+
observedViolationCount: 5,
|
|
76
|
+
complianceRate: 0.5,
|
|
77
|
+
violationTrend: 0,
|
|
78
|
+
generatedSampleCount: 0,
|
|
79
|
+
approvedSampleCount: 0,
|
|
80
|
+
includedTrainRunIds: [],
|
|
81
|
+
deployedCheckpointIds: [],
|
|
82
|
+
internalizationStatus: 'prompt_only',
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const principle = createLedgerPrinciple('P_test_001');
|
|
87
|
+
|
|
88
|
+
const tree = {
|
|
89
|
+
principles: { 'P_test_001': principle },
|
|
90
|
+
rules: {} as Record<string, LedgerRule>,
|
|
91
|
+
implementations: {},
|
|
92
|
+
metrics: {},
|
|
93
|
+
lastUpdated: new Date().toISOString(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const store: HybridLedgerStore = {
|
|
97
|
+
trainingStore,
|
|
98
|
+
tree,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
saveLedger(stateDir, store);
|
|
102
|
+
|
|
103
|
+
// Create stub bootstrap rule
|
|
104
|
+
const ruleId = 'P_test_001_stub_bootstrap';
|
|
105
|
+
const rule = createRule(stateDir, {
|
|
106
|
+
id: ruleId,
|
|
107
|
+
version: 1,
|
|
108
|
+
name: 'Stub bootstrap rule for P_test_001',
|
|
109
|
+
description: 'Placeholder rule for principle-internalization bootstrap',
|
|
110
|
+
type: 'hook',
|
|
111
|
+
triggerCondition: 'stub: bootstrap placeholder',
|
|
112
|
+
enforcement: 'warn',
|
|
113
|
+
action: 'allow (stub)',
|
|
114
|
+
principleId: 'P_test_001',
|
|
115
|
+
status: 'proposed',
|
|
116
|
+
coverageRate: 0,
|
|
117
|
+
falsePositiveRate: 0,
|
|
118
|
+
implementationIds: [],
|
|
119
|
+
createdAt: new Date().toISOString(),
|
|
120
|
+
updatedAt: new Date().toISOString(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Link rule to principle
|
|
124
|
+
updatePrinciple(stateDir, 'P_test_001', {
|
|
125
|
+
suggestedRules: [ruleId],
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('script file existence', () => {
|
|
130
|
+
it('should have validate-live-path.ts script file', () => {
|
|
131
|
+
expect(fs.existsSync(SCRIPT_PATH)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should have minimum 150 lines', () => {
|
|
135
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
136
|
+
const lineCount = content.split('\n').length;
|
|
137
|
+
expect(lineCount).toBeGreaterThanOrEqual(150);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('script imports and patterns', () => {
|
|
142
|
+
it('should implement acquireLockAsync and releaseLock functions', () => {
|
|
143
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
144
|
+
expect(content).toMatch(/acquireLockAsync/);
|
|
145
|
+
expect(content).toMatch(/releaseLock/);
|
|
146
|
+
// Script is standalone, so it doesn't import from file-lock.js
|
|
147
|
+
// It implements its own simplified lock functions
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should use better-sqlite3 for direct SQLite query', () => {
|
|
151
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
152
|
+
expect(content).toMatch(/better-sqlite3/);
|
|
153
|
+
expect(content).toMatch(/workflow_type\s*=\s*['"]nocturnal['"]/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should filter for _stub_bootstrap rules', () => {
|
|
157
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
158
|
+
expect(content).toMatch(/_stub_bootstrap/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should have proper exit codes', () => {
|
|
162
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
163
|
+
expect(content).toMatch(/process\.exit\(0\)/);
|
|
164
|
+
expect(content).toMatch(/process\.exit\(1\)/);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('bootstrapped rule detection', () => {
|
|
169
|
+
it('should detect bootstrapped rules from ledger', () => {
|
|
170
|
+
setupBootstrapLedger();
|
|
171
|
+
|
|
172
|
+
const ledger = loadLedger(stateDir);
|
|
173
|
+
const bootstrappedRules = Object.values(ledger.tree.rules).filter(r =>
|
|
174
|
+
r.id.endsWith('_stub_bootstrap')
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(bootstrappedRules.length).toBeGreaterThan(0);
|
|
178
|
+
expect(bootstrappedRules[0].id).toBe('P_test_001_stub_bootstrap');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should fail fast when no bootstrapped rules exist', () => {
|
|
182
|
+
// Create empty ledger
|
|
183
|
+
const store: HybridLedgerStore = {
|
|
184
|
+
trainingStore: {},
|
|
185
|
+
tree: {
|
|
186
|
+
principles: {},
|
|
187
|
+
rules: {},
|
|
188
|
+
implementations: {},
|
|
189
|
+
metrics: {},
|
|
190
|
+
lastUpdated: new Date().toISOString(),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
saveLedger(stateDir, store);
|
|
194
|
+
|
|
195
|
+
const ledger = loadLedger(stateDir);
|
|
196
|
+
const bootstrappedRules = Object.values(ledger.tree.rules).filter(r =>
|
|
197
|
+
r.id.endsWith('_stub_bootstrap')
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(bootstrappedRules.length).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('synthetic snapshot construction', () => {
|
|
205
|
+
it('should create snapshot with recentPain to pass hasUsableNocturnalSnapshot guard', () => {
|
|
206
|
+
// This test verifies the snapshot shape from plan context
|
|
207
|
+
const snapshot = {
|
|
208
|
+
sessionId: `validation-${Date.now()}`,
|
|
209
|
+
startedAt: new Date().toISOString(),
|
|
210
|
+
updatedAt: new Date().toISOString(),
|
|
211
|
+
assistantTurns: [],
|
|
212
|
+
userTurns: [],
|
|
213
|
+
toolCalls: [],
|
|
214
|
+
painEvents: [],
|
|
215
|
+
gateBlocks: [],
|
|
216
|
+
stats: {
|
|
217
|
+
totalAssistantTurns: 0,
|
|
218
|
+
totalToolCalls: 0,
|
|
219
|
+
failureCount: 0,
|
|
220
|
+
totalPainEvents: 1,
|
|
221
|
+
totalGateBlocks: 0,
|
|
222
|
+
},
|
|
223
|
+
recentPain: [{
|
|
224
|
+
source: 'live-validation',
|
|
225
|
+
score: 50,
|
|
226
|
+
severity: 'moderate',
|
|
227
|
+
reason: 'Synthetic snapshot for live path validation',
|
|
228
|
+
createdAt: new Date().toISOString(),
|
|
229
|
+
}],
|
|
230
|
+
_dataSource: 'pain_context_fallback',
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Verify snapshot has required fields
|
|
234
|
+
expect(snapshot.sessionId).toBeTruthy();
|
|
235
|
+
expect(snapshot.sessionId.length).toBeGreaterThan(0);
|
|
236
|
+
expect(snapshot.recentPain).toBeDefined();
|
|
237
|
+
expect(snapshot.recentPain!.length).toBeGreaterThan(0);
|
|
238
|
+
expect(snapshot._dataSource).toBe('pain_context_fallback');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('queue file locking', () => {
|
|
243
|
+
it('should use acquireLockAsync before writing to queue', () => {
|
|
244
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
245
|
+
// Check for lock acquisition pattern
|
|
246
|
+
expect(content).toMatch(/acquireLockAsync/);
|
|
247
|
+
expect(content).toMatch(/QUEUE_PATH/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should release lock in finally block', () => {
|
|
251
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
252
|
+
// Check for finally block with lock release
|
|
253
|
+
expect(content).toMatch(/\}\s*finally/);
|
|
254
|
+
expect(content).toMatch(/if\s+\(lockCtx\)/);
|
|
255
|
+
expect(content).toMatch(/releaseLock\(lockCtx\)/);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('workflow store query', () => {
|
|
260
|
+
it('should query subagent_workflows.db directly for nocturnal workflows', () => {
|
|
261
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
262
|
+
// Check for raw SQLite query pattern
|
|
263
|
+
expect(content).toMatch(/workflow_type\s*=\s*['"]nocturnal['"]/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should correlate workflow to queue item via taskId', () => {
|
|
267
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
268
|
+
// Check for taskId correlation pattern
|
|
269
|
+
expect(content).toMatch(/taskId|metadata_json/);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('resolution verification', () => {
|
|
274
|
+
it('should read resolution from queue item, not from WorkflowRow', () => {
|
|
275
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
276
|
+
// Check for resolution field access on queue
|
|
277
|
+
expect(content).toMatch(/resolution.*queue|queue.*resolution/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should verify explicit resolution (not expired)', () => {
|
|
281
|
+
const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
282
|
+
// Check for explicit resolution check
|
|
283
|
+
expect(content).toMatch(/resolution.*expired|expired.*resolution/);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../src/core/dictionary-service.js', () => ({
|
|
7
|
+
DictionaryService: {
|
|
8
|
+
get: vi.fn(() => ({ flush: vi.fn() })),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../../src/core/session-tracker.js', () => ({
|
|
13
|
+
initPersistence: vi.fn(),
|
|
14
|
+
flushAllSessions: vi.fn(),
|
|
15
|
+
listSessions: vi.fn(() => []),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const { mockStartWorkflow, mockGetWorkflowDebugSummary } = vi.hoisted(() => ({
|
|
19
|
+
mockStartWorkflow: vi.fn(),
|
|
20
|
+
mockGetWorkflowDebugSummary: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('../../src/service/subagent-workflow/nocturnal-workflow-manager.js', () => ({
|
|
24
|
+
NocturnalWorkflowManager: class {
|
|
25
|
+
startWorkflow = mockStartWorkflow;
|
|
26
|
+
getWorkflowDebugSummary = mockGetWorkflowDebugSummary;
|
|
27
|
+
},
|
|
28
|
+
nocturnalWorkflowSpec: {
|
|
29
|
+
workflowType: 'nocturnal',
|
|
30
|
+
transport: 'runtime_direct',
|
|
31
|
+
timeoutMs: 15 * 60 * 1000,
|
|
32
|
+
ttlMs: 30 * 60 * 1000,
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const { mockGetNocturnalSessionSnapshot } = vi.hoisted(() => ({
|
|
37
|
+
mockGetNocturnalSessionSnapshot: vi.fn(),
|
|
38
|
+
}));
|
|
39
|
+
vi.mock('../../src/core/nocturnal-trajectory-extractor.js', async () => {
|
|
40
|
+
const actual = await vi.importActual<typeof import('../../src/core/nocturnal-trajectory-extractor.js')>(
|
|
41
|
+
'../../src/core/nocturnal-trajectory-extractor.js'
|
|
42
|
+
);
|
|
43
|
+
return {
|
|
44
|
+
...actual,
|
|
45
|
+
createNocturnalTrajectoryExtractor: vi.fn(() => ({
|
|
46
|
+
getNocturnalSessionSnapshot: mockGetNocturnalSessionSnapshot,
|
|
47
|
+
})),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
import { EvolutionWorkerService } from '../../src/service/evolution-worker.js';
|
|
52
|
+
import { safeRmDir } from '../test-utils.js';
|
|
53
|
+
|
|
54
|
+
function readQueue(stateDir: string) {
|
|
55
|
+
return JSON.parse(fs.readFileSync(path.join(stateDir, 'evolution_queue.json'), 'utf8'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('EvolutionWorkerService nocturnal hardening', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.useFakeTimers();
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
EvolutionWorkerService.api = null;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.useRealTimers();
|
|
67
|
+
EvolutionWorkerService.api = null;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not start a nocturnal workflow when only an empty fallback snapshot is available', async () => {
|
|
71
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-empty-'));
|
|
72
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
73
|
+
fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
|
|
74
|
+
fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
|
|
75
|
+
|
|
76
|
+
mockGetNocturnalSessionSnapshot.mockReturnValue(null);
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
path.join(stateDir, 'evolution_queue.json'),
|
|
80
|
+
JSON.stringify([
|
|
81
|
+
{
|
|
82
|
+
id: 'sleep-empty',
|
|
83
|
+
taskKind: 'sleep_reflection',
|
|
84
|
+
priority: 'medium',
|
|
85
|
+
score: 50,
|
|
86
|
+
source: 'nocturnal',
|
|
87
|
+
reason: 'Sleep reflection',
|
|
88
|
+
timestamp: '2026-04-10T00:00:00.000Z',
|
|
89
|
+
enqueued_at: '2026-04-10T00:00:00.000Z',
|
|
90
|
+
status: 'pending',
|
|
91
|
+
retryCount: 0,
|
|
92
|
+
maxRetries: 1,
|
|
93
|
+
recentPainContext: {
|
|
94
|
+
mostRecent: null,
|
|
95
|
+
recentPainCount: 0,
|
|
96
|
+
recentMaxPainScore: 0,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
], null, 2),
|
|
100
|
+
'utf8'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
EvolutionWorkerService.start({
|
|
105
|
+
workspaceDir,
|
|
106
|
+
stateDir,
|
|
107
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
108
|
+
config: {},
|
|
109
|
+
} as any);
|
|
110
|
+
|
|
111
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
112
|
+
|
|
113
|
+
const queue = readQueue(stateDir);
|
|
114
|
+
expect(queue[0].status).toBe('failed');
|
|
115
|
+
expect(queue[0].lastError).toContain('missing_usable_snapshot');
|
|
116
|
+
expect(queue[0].resultRef).toBeFalsy();
|
|
117
|
+
expect(mockStartWorkflow).not.toHaveBeenCalled();
|
|
118
|
+
} finally {
|
|
119
|
+
EvolutionWorkerService.stop({ workspaceDir, stateDir, logger: console } as any);
|
|
120
|
+
safeRmDir(workspaceDir);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps gateway-only background failures as failed instead of completed stub fallback', async () => {
|
|
125
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-gateway-'));
|
|
126
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
127
|
+
fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
|
|
128
|
+
fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
|
|
129
|
+
|
|
130
|
+
mockGetNocturnalSessionSnapshot.mockReturnValue({
|
|
131
|
+
sessionId: 'sleep-gateway',
|
|
132
|
+
startedAt: '2026-04-10T00:00:00.000Z',
|
|
133
|
+
updatedAt: '2026-04-10T00:01:00.000Z',
|
|
134
|
+
assistantTurns: [],
|
|
135
|
+
userTurns: [],
|
|
136
|
+
toolCalls: [],
|
|
137
|
+
painEvents: [],
|
|
138
|
+
gateBlocks: [],
|
|
139
|
+
stats: {
|
|
140
|
+
totalAssistantTurns: 1,
|
|
141
|
+
totalToolCalls: 1,
|
|
142
|
+
totalPainEvents: 0,
|
|
143
|
+
totalGateBlocks: 0,
|
|
144
|
+
failureCount: 0,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
mockStartWorkflow.mockResolvedValue({ workflowId: 'wf-1', childSessionKey: 'child-1', state: 'active' });
|
|
148
|
+
mockGetWorkflowDebugSummary.mockResolvedValue({
|
|
149
|
+
state: 'terminal_error',
|
|
150
|
+
metadata: {},
|
|
151
|
+
recentEvents: [
|
|
152
|
+
{
|
|
153
|
+
reason: 'Error: Plugin runtime subagent methods are only available during a gateway request.',
|
|
154
|
+
payload: {},
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
EvolutionWorkerService.api = {
|
|
160
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
161
|
+
runtime: {},
|
|
162
|
+
} as any;
|
|
163
|
+
|
|
164
|
+
fs.writeFileSync(
|
|
165
|
+
path.join(stateDir, 'evolution_queue.json'),
|
|
166
|
+
JSON.stringify([
|
|
167
|
+
{
|
|
168
|
+
id: 'sleep-gateway',
|
|
169
|
+
taskKind: 'sleep_reflection',
|
|
170
|
+
priority: 'medium',
|
|
171
|
+
score: 50,
|
|
172
|
+
source: 'nocturnal',
|
|
173
|
+
reason: 'Sleep reflection',
|
|
174
|
+
timestamp: '2026-04-10T00:00:00.000Z',
|
|
175
|
+
enqueued_at: '2026-04-10T00:00:00.000Z',
|
|
176
|
+
status: 'pending',
|
|
177
|
+
retryCount: 0,
|
|
178
|
+
maxRetries: 1,
|
|
179
|
+
recentPainContext: {
|
|
180
|
+
mostRecent: { score: 0.5, source: 'pain', reason: 'x', timestamp: '2026-04-10T00:00:00.000Z' },
|
|
181
|
+
recentPainCount: 1,
|
|
182
|
+
recentMaxPainScore: 0.5,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
], null, 2),
|
|
186
|
+
'utf8'
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
EvolutionWorkerService.start({
|
|
191
|
+
workspaceDir,
|
|
192
|
+
stateDir,
|
|
193
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
194
|
+
config: {},
|
|
195
|
+
} as any);
|
|
196
|
+
|
|
197
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
198
|
+
|
|
199
|
+
const queue = readQueue(stateDir);
|
|
200
|
+
expect(queue[0].status).toBe('failed');
|
|
201
|
+
expect(queue[0].resolution).toBe('failed_max_retries');
|
|
202
|
+
expect(queue[0].lastError).toContain('gateway request');
|
|
203
|
+
} finally {
|
|
204
|
+
EvolutionWorkerService.stop({ workspaceDir, stateDir, logger: console } as any);
|
|
205
|
+
safeRmDir(workspaceDir);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { WorkflowEventRow, WorkflowRow } from '../../src/service/subagent-workflow/types.js';
|
|
3
|
+
|
|
4
|
+
const mockListWorkflows = vi.fn<() => WorkflowRow[]>();
|
|
5
|
+
const mockGetWorkflow = vi.fn<(workflowId: string) => WorkflowRow | null>();
|
|
6
|
+
const mockGetEvents = vi.fn<(workflowId: string) => WorkflowEventRow[]>();
|
|
7
|
+
const mockGetStageOutputs = vi.fn<(workflowId: string) => Array<{ stage: string }>>();
|
|
8
|
+
const mockDispose = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock('../../src/service/subagent-workflow/workflow-store.js', () => ({
|
|
11
|
+
WorkflowStore: class {
|
|
12
|
+
listWorkflows = mockListWorkflows;
|
|
13
|
+
getWorkflow = mockGetWorkflow;
|
|
14
|
+
getEvents = mockGetEvents;
|
|
15
|
+
getStageOutputs = mockGetStageOutputs;
|
|
16
|
+
dispose = mockDispose;
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { MonitoringQueryService } from '../../src/service/monitoring-query-service.js';
|
|
21
|
+
|
|
22
|
+
function createWorkflow(overrides: Partial<WorkflowRow> = {}): WorkflowRow {
|
|
23
|
+
return {
|
|
24
|
+
workflow_id: overrides.workflow_id ?? 'wf-1',
|
|
25
|
+
workflow_type: overrides.workflow_type ?? 'nocturnal',
|
|
26
|
+
transport: overrides.transport ?? 'runtime_direct',
|
|
27
|
+
parent_session_id: overrides.parent_session_id ?? 'parent-1',
|
|
28
|
+
child_session_key: overrides.child_session_key ?? 'child-1',
|
|
29
|
+
run_id: overrides.run_id ?? null,
|
|
30
|
+
state: overrides.state ?? 'completed',
|
|
31
|
+
cleanup_state: overrides.cleanup_state ?? 'none',
|
|
32
|
+
created_at: overrides.created_at ?? Date.UTC(2026, 3, 10, 0, 0, 0),
|
|
33
|
+
updated_at: overrides.updated_at ?? Date.UTC(2026, 3, 10, 0, 5, 0),
|
|
34
|
+
last_observed_at: overrides.last_observed_at ?? null,
|
|
35
|
+
duration_ms: overrides.duration_ms ?? 1_000,
|
|
36
|
+
metadata_json: overrides.metadata_json ?? '{}',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createEvent(
|
|
41
|
+
workflowId: string,
|
|
42
|
+
eventType: string,
|
|
43
|
+
createdAt: number,
|
|
44
|
+
reason = ''
|
|
45
|
+
): WorkflowEventRow {
|
|
46
|
+
return {
|
|
47
|
+
workflow_id: workflowId,
|
|
48
|
+
event_type: eventType,
|
|
49
|
+
from_state: null,
|
|
50
|
+
to_state: 'completed',
|
|
51
|
+
reason,
|
|
52
|
+
payload_json: '{}',
|
|
53
|
+
created_at: createdAt,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('MonitoringQueryService', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.clearAllMocks();
|
|
60
|
+
mockListWorkflows.mockReturnValue([]);
|
|
61
|
+
mockGetWorkflow.mockReturnValue(null);
|
|
62
|
+
mockGetEvents.mockReturnValue([]);
|
|
63
|
+
mockGetStageOutputs.mockReturnValue([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('ignores malformed workflow metadata when listing workflows', () => {
|
|
67
|
+
mockListWorkflows.mockReturnValue([
|
|
68
|
+
createWorkflow({
|
|
69
|
+
workflow_id: 'wf-malformed',
|
|
70
|
+
metadata_json: '{invalid',
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const service = new MonitoringQueryService('/workspace');
|
|
75
|
+
const result = service.getWorkflows();
|
|
76
|
+
|
|
77
|
+
expect(result.workflows).toHaveLength(1);
|
|
78
|
+
expect(result.workflows[0]).toMatchObject({
|
|
79
|
+
workflowId: 'wf-malformed',
|
|
80
|
+
state: 'completed',
|
|
81
|
+
stuckDuration: null,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('computes failure rate per terminal workflow instead of per failed stage', () => {
|
|
86
|
+
mockListWorkflows.mockReturnValue([
|
|
87
|
+
createWorkflow({ workflow_id: 'wf-complete', state: 'completed', duration_ms: 500 }),
|
|
88
|
+
createWorkflow({ workflow_id: 'wf-failed', state: 'terminal_error', duration_ms: 750 }),
|
|
89
|
+
]);
|
|
90
|
+
mockGetEvents.mockImplementation((workflowId: string) => {
|
|
91
|
+
if (workflowId === 'wf-failed') {
|
|
92
|
+
return [
|
|
93
|
+
createEvent(workflowId, 'trinity_dreamer_start', 1),
|
|
94
|
+
createEvent(workflowId, 'trinity_dreamer_failed', 2, 'dreamer failed'),
|
|
95
|
+
createEvent(workflowId, 'trinity_philosopher_start', 3),
|
|
96
|
+
createEvent(workflowId, 'trinity_philosopher_failed', 4, 'philosopher failed'),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
return [
|
|
100
|
+
createEvent(workflowId, 'trinity_dreamer_start', 1),
|
|
101
|
+
createEvent(workflowId, 'trinity_dreamer_complete', 2),
|
|
102
|
+
];
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const service = new MonitoringQueryService('/workspace');
|
|
106
|
+
const result = service.getTrinityHealth();
|
|
107
|
+
|
|
108
|
+
expect(result.totalCalls).toBe(2);
|
|
109
|
+
expect(result.failureRate).toBe(0.5);
|
|
110
|
+
expect(result.stageStats.dreamer.failed).toBe(1);
|
|
111
|
+
expect(result.stageStats.philosopher.failed).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
});
|