@principles/pd-cli 1.100.0 → 1.102.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/dist/commands/diagnose.js +27 -27
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/pain-retry.d.ts.map +1 -1
- package/dist/commands/pain-retry.js +22 -27
- package/dist/commands/pain-retry.js.map +1 -1
- package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-run-once.js +11 -9
- package/dist/commands/runtime-internalization-run-once.js.map +1 -1
- package/dist/commands/runtime.d.ts +1 -1
- package/dist/commands/runtime.d.ts.map +1 -1
- package/dist/commands/runtime.js +92 -25
- package/dist/commands/runtime.js.map +1 -1
- package/dist/commands/task.js +2 -2
- package/dist/commands/task.js.map +1 -1
- package/dist/services/resolve-runtime-from-pd-config.d.ts +59 -0
- package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -0
- package/dist/services/resolve-runtime-from-pd-config.js +96 -0
- package/dist/services/resolve-runtime-from-pd-config.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/diagnose.ts +26 -26
- package/src/commands/pain-retry.ts +21 -25
- package/src/commands/runtime-internalization-run-once.ts +10 -9
- package/src/commands/runtime.ts +96 -24
- package/src/commands/task.ts +2 -2
- package/src/services/resolve-runtime-from-pd-config.ts +142 -0
- package/tests/commands/console-launcher-edge-cases.test.ts +14 -47
- package/tests/commands/diagnose.test.ts +91 -39
- package/tests/commands/pain-retry.test.ts +130 -15
- package/tests/commands/pri-393-runtime-config-unification.test.ts +284 -0
- package/tests/commands/runtime-internalization-integrity-repair.test.ts +38 -0
- package/tests/commands/runtime-internalization-run-once.test.ts +59 -53
- package/tests/commands/runtime.test.ts +124 -1
- package/tests/commands/task.test.ts +9 -1
|
@@ -41,6 +41,23 @@ const { MockPrincipleTreeLedgerAdapter } = vi.hoisted(() => {
|
|
|
41
41
|
return { MockPrincipleTreeLedgerAdapter };
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
const { mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
|
|
45
|
+
const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
|
|
46
|
+
result: {
|
|
47
|
+
runtimeKind: 'pi-ai',
|
|
48
|
+
provider: 'test-provider',
|
|
49
|
+
model: 'test-model',
|
|
50
|
+
apiKeyEnv: 'TEST_KEY',
|
|
51
|
+
timeoutMs: 300000,
|
|
52
|
+
agentId: 'main',
|
|
53
|
+
},
|
|
54
|
+
legacyWarnings: [],
|
|
55
|
+
configSource: '.pd/config.yaml',
|
|
56
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
57
|
+
});
|
|
58
|
+
return { mockResolveRuntimeFromPdConfig };
|
|
59
|
+
});
|
|
60
|
+
|
|
44
61
|
vi.mock('../../src/resolve-workspace.js', () => ({
|
|
45
62
|
resolveWorkspaceDir: vi.fn().mockReturnValue('/tmp/fake-workspace'),
|
|
46
63
|
}));
|
|
@@ -122,6 +139,10 @@ vi.mock('../../src/services/pd-config-loader.js', () => ({
|
|
|
122
139
|
computeFlagsFromLoadResult: vi.fn().mockReturnValue({}),
|
|
123
140
|
}));
|
|
124
141
|
|
|
142
|
+
vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
|
|
143
|
+
resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
|
|
144
|
+
}));
|
|
145
|
+
|
|
125
146
|
import { handleDiagnoseRun, handleDiagnoseStatus, type DiagnoseRunOptions } from '../../src/commands/diagnose.js';
|
|
126
147
|
|
|
127
148
|
const SUCCEEDED_RESULT = {
|
|
@@ -177,14 +198,18 @@ describe('pd diagnose run --runtime routing', () => {
|
|
|
177
198
|
exitSpy.mockRestore();
|
|
178
199
|
});
|
|
179
200
|
|
|
180
|
-
it('HG-03: --runtime openclaw-cli without mode (no file config) fails via
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
201
|
+
it('HG-03: --runtime openclaw-cli without mode (no file config) fails via resolveRuntimeFromPdConfig', async () => {
|
|
202
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
203
|
+
result: {
|
|
204
|
+
ok: false,
|
|
205
|
+
reason: 'missing_openclaw_mode',
|
|
206
|
+
message: 'runtimeKind is openclaw-cli but no mode specified',
|
|
207
|
+
nextAction: 'Provide exactly one mode',
|
|
208
|
+
},
|
|
209
|
+
legacyWarnings: [],
|
|
210
|
+
configSource: '.pd/config.yaml',
|
|
211
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
186
212
|
});
|
|
187
|
-
mockIsRuntimeConfigError.mockReturnValueOnce(true);
|
|
188
213
|
|
|
189
214
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
190
215
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
@@ -196,7 +221,7 @@ describe('pd diagnose run --runtime routing', () => {
|
|
|
196
221
|
json: false,
|
|
197
222
|
} as DiagnoseRunOptions);
|
|
198
223
|
|
|
199
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('no mode
|
|
224
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('no mode resolved'));
|
|
200
225
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
201
226
|
|
|
202
227
|
consoleErrorSpy.mockRestore();
|
|
@@ -226,13 +251,17 @@ describe('pd diagnose run --runtime routing', () => {
|
|
|
226
251
|
});
|
|
227
252
|
|
|
228
253
|
it('DPB-09: openclaw-cli with file config openclawMode succeeds without CLI flag', async () => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
254
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
255
|
+
result: {
|
|
256
|
+
runtimeKind: 'openclaw-cli',
|
|
257
|
+
openclawMode: 'local',
|
|
258
|
+
timeoutMs: 300000,
|
|
259
|
+
agentId: 'main',
|
|
260
|
+
},
|
|
261
|
+
legacyWarnings: [],
|
|
262
|
+
configSource: '.pd/config.yaml',
|
|
263
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
234
264
|
});
|
|
235
|
-
mockIsRuntimeConfigError.mockReturnValueOnce(false);
|
|
236
265
|
|
|
237
266
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
238
267
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
@@ -250,14 +279,18 @@ describe('pd diagnose run --runtime routing', () => {
|
|
|
250
279
|
exitSpy.mockRestore();
|
|
251
280
|
});
|
|
252
281
|
|
|
253
|
-
it('DPB-09: openclaw-cli flag overrides file config mode', async () => {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
282
|
+
it('DPB-09: openclaw-cli flag overrides file config mode (config=gateway, flag=local → runtimeMode=local)', async () => {
|
|
283
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
284
|
+
result: {
|
|
285
|
+
runtimeKind: 'openclaw-cli',
|
|
286
|
+
openclawMode: 'gateway',
|
|
287
|
+
timeoutMs: 300000,
|
|
288
|
+
agentId: 'main',
|
|
289
|
+
},
|
|
290
|
+
legacyWarnings: [],
|
|
291
|
+
configSource: '.pd/config.yaml',
|
|
292
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
259
293
|
});
|
|
260
|
-
mockIsRuntimeConfigError.mockReturnValueOnce(false);
|
|
261
294
|
|
|
262
295
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
263
296
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
@@ -270,6 +303,13 @@ describe('pd diagnose run --runtime routing', () => {
|
|
|
270
303
|
json: false,
|
|
271
304
|
} as DiagnoseRunOptions);
|
|
272
305
|
|
|
306
|
+
// Flag override: config says gateway, flag says local → adapter gets local
|
|
307
|
+
const OpenClawCliMock = vi.mocked(
|
|
308
|
+
await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
|
|
309
|
+
);
|
|
310
|
+
expect(OpenClawCliMock).toHaveBeenCalledWith(
|
|
311
|
+
expect.objectContaining({ runtimeMode: 'local' }),
|
|
312
|
+
);
|
|
273
313
|
expect(exitSpy).not.toHaveBeenCalledWith(1);
|
|
274
314
|
|
|
275
315
|
consoleSpy.mockRestore();
|
|
@@ -277,13 +317,17 @@ describe('pd diagnose run --runtime routing', () => {
|
|
|
277
317
|
});
|
|
278
318
|
|
|
279
319
|
it('DPB-09: openclaw-cli missing mode (--json) outputs JSON error', async () => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
320
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
321
|
+
result: {
|
|
322
|
+
runtimeKind: 'openclaw-cli',
|
|
323
|
+
openclawMode: undefined,
|
|
324
|
+
timeoutMs: 300000,
|
|
325
|
+
agentId: 'main',
|
|
326
|
+
},
|
|
327
|
+
legacyWarnings: [],
|
|
328
|
+
configSource: '.pd/config.yaml',
|
|
329
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
285
330
|
});
|
|
286
|
-
mockIsRuntimeConfigError.mockReturnValueOnce(true);
|
|
287
331
|
|
|
288
332
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
289
333
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
@@ -323,13 +367,17 @@ describe('pd diagnose run --runtime routing', () => {
|
|
|
323
367
|
});
|
|
324
368
|
|
|
325
369
|
it('DPB-09: openclaw-cli --openclaw-gateway constructs adapter with runtimeMode=gateway', async () => {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
370
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
371
|
+
result: {
|
|
372
|
+
runtimeKind: 'openclaw-cli',
|
|
373
|
+
openclawMode: 'gateway',
|
|
374
|
+
timeoutMs: 300000,
|
|
375
|
+
agentId: 'main',
|
|
376
|
+
},
|
|
377
|
+
legacyWarnings: [],
|
|
378
|
+
configSource: '.pd/config.yaml',
|
|
379
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
331
380
|
});
|
|
332
|
-
mockIsRuntimeConfigError.mockReturnValueOnce(false);
|
|
333
381
|
|
|
334
382
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
335
383
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
@@ -355,13 +403,17 @@ describe('pd diagnose run --runtime routing', () => {
|
|
|
355
403
|
});
|
|
356
404
|
|
|
357
405
|
it('DPB-09: openclaw-cli --openclaw-local constructs adapter with runtimeMode=local', async () => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
406
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
407
|
+
result: {
|
|
408
|
+
runtimeKind: 'openclaw-cli',
|
|
409
|
+
openclawMode: 'local',
|
|
410
|
+
timeoutMs: 300000,
|
|
411
|
+
agentId: 'main',
|
|
412
|
+
},
|
|
413
|
+
legacyWarnings: [],
|
|
414
|
+
configSource: '.pd/config.yaml',
|
|
415
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
363
416
|
});
|
|
364
|
-
mockIsRuntimeConfigError.mockReturnValueOnce(false);
|
|
365
417
|
|
|
366
418
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
367
419
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
@@ -55,7 +55,7 @@ const { MockPrincipleTreeLedgerAdapter } = vi.hoisted(() => {
|
|
|
55
55
|
return { MockPrincipleTreeLedgerAdapter };
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
const { mockRun, mockResolveRuntimeConfig } = vi.hoisted(() => {
|
|
58
|
+
const { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
|
|
59
59
|
const mockRun = vi.fn().mockResolvedValue({
|
|
60
60
|
status: 'succeeded',
|
|
61
61
|
taskId: 'diagnosis_test-pain-1',
|
|
@@ -70,7 +70,20 @@ const { mockRun, mockResolveRuntimeConfig } = vi.hoisted(() => {
|
|
|
70
70
|
timeoutMs: 300000,
|
|
71
71
|
agentId: 'main',
|
|
72
72
|
});
|
|
73
|
-
|
|
73
|
+
const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
|
|
74
|
+
result: {
|
|
75
|
+
runtimeKind: 'pi-ai',
|
|
76
|
+
provider: 'test-provider',
|
|
77
|
+
model: 'test-model',
|
|
78
|
+
apiKeyEnv: 'TEST_KEY',
|
|
79
|
+
timeoutMs: 300000,
|
|
80
|
+
agentId: 'main',
|
|
81
|
+
},
|
|
82
|
+
legacyWarnings: [],
|
|
83
|
+
configSource: '.pd/config.yaml',
|
|
84
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
85
|
+
});
|
|
86
|
+
return { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig };
|
|
74
87
|
});
|
|
75
88
|
|
|
76
89
|
vi.mock('../../src/resolve-workspace.js', () => ({
|
|
@@ -128,6 +141,10 @@ vi.mock('../../src/config-reader.js', () => ({
|
|
|
128
141
|
readOutputLanguageFromWorkspace: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
|
|
129
142
|
}));
|
|
130
143
|
|
|
144
|
+
vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
|
|
145
|
+
resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
|
|
146
|
+
}));
|
|
147
|
+
|
|
131
148
|
import { handlePainRetry } from '../../src/commands/pain-retry.js';
|
|
132
149
|
|
|
133
150
|
// ── Test Data ──────────────────────────────────────────────────────────────────
|
|
@@ -201,6 +218,19 @@ describe('pd pain retry — validation and error paths', () => {
|
|
|
201
218
|
timeoutMs: 300000,
|
|
202
219
|
agentId: 'main',
|
|
203
220
|
});
|
|
221
|
+
mockResolveRuntimeFromPdConfig.mockReturnValue({
|
|
222
|
+
result: {
|
|
223
|
+
runtimeKind: 'pi-ai',
|
|
224
|
+
provider: 'test-provider',
|
|
225
|
+
model: 'test-model',
|
|
226
|
+
apiKeyEnv: 'TEST_KEY',
|
|
227
|
+
timeoutMs: 300000,
|
|
228
|
+
agentId: 'main',
|
|
229
|
+
},
|
|
230
|
+
legacyWarnings: [],
|
|
231
|
+
configSource: '.pd/config.yaml',
|
|
232
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
233
|
+
});
|
|
204
234
|
mockGetTask.mockResolvedValue(null);
|
|
205
235
|
mockGetCandidatesByTaskId.mockResolvedValue([]);
|
|
206
236
|
mockUpdateCandidateStatus.mockResolvedValue(undefined);
|
|
@@ -342,11 +372,16 @@ describe('pd pain retry — validation and error paths', () => {
|
|
|
342
372
|
|
|
343
373
|
it('RETRY-05a: missing --runtime and no config — refused with reason + nextAction (JSON)', async () => {
|
|
344
374
|
mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
375
|
+
// PRI-393: resolveRuntimeFromPdConfig returns error → no runtime resolved
|
|
376
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
377
|
+
result: {
|
|
378
|
+
reason: 'config_not_found',
|
|
379
|
+
message: 'No .pd/config.yaml found',
|
|
380
|
+
nextAction: 'Create .pd/config.yaml or pass --runtime',
|
|
381
|
+
},
|
|
382
|
+
legacyWarnings: [],
|
|
383
|
+
configSource: '.pd/config.yaml',
|
|
384
|
+
configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
350
385
|
});
|
|
351
386
|
|
|
352
387
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -404,14 +439,19 @@ describe('pd pain retry — validation and error paths', () => {
|
|
|
404
439
|
|
|
405
440
|
it('RETRY-05c: blank provider/model/apiKeyEnv — refused with missing_required_config', async () => {
|
|
406
441
|
mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
442
|
+
// PRI-393: resolveRuntimeFromPdConfig returns config with blank strings
|
|
443
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
444
|
+
result: {
|
|
445
|
+
runtimeKind: 'pi-ai',
|
|
446
|
+
provider: '',
|
|
447
|
+
model: ' ',
|
|
448
|
+
apiKeyEnv: '',
|
|
449
|
+
timeoutMs: 300000,
|
|
450
|
+
agentId: 'main',
|
|
451
|
+
},
|
|
452
|
+
legacyWarnings: [],
|
|
453
|
+
configSource: '.pd/config.yaml',
|
|
454
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
415
455
|
});
|
|
416
456
|
|
|
417
457
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -440,6 +480,81 @@ describe('pd pain retry — validation and error paths', () => {
|
|
|
440
480
|
logSpy.mockRestore();
|
|
441
481
|
exitSpy.mockRestore();
|
|
442
482
|
});
|
|
483
|
+
|
|
484
|
+
it('DPB-09: openclaw-cli flag overrides file config mode (config=gateway, flag=local → runtimeMode=local)', async () => {
|
|
485
|
+
mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
|
|
486
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
487
|
+
result: {
|
|
488
|
+
runtimeKind: 'openclaw-cli',
|
|
489
|
+
openclawMode: 'gateway',
|
|
490
|
+
timeoutMs: 300000,
|
|
491
|
+
agentId: 'main',
|
|
492
|
+
},
|
|
493
|
+
legacyWarnings: [],
|
|
494
|
+
configSource: '.pd/config.yaml',
|
|
495
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
499
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
500
|
+
|
|
501
|
+
await handlePainRetry({
|
|
502
|
+
painId: 'test-pain-1',
|
|
503
|
+
workspace: '/tmp/fake-workspace',
|
|
504
|
+
runtime: 'openclaw-cli',
|
|
505
|
+
openclawLocal: true,
|
|
506
|
+
json: true,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Flag override: config says gateway, flag says local → adapter gets local
|
|
510
|
+
const OpenClawCliMock = vi.mocked(
|
|
511
|
+
await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
|
|
512
|
+
);
|
|
513
|
+
expect(OpenClawCliMock).toHaveBeenCalledWith(
|
|
514
|
+
expect.objectContaining({ runtimeMode: 'local' }),
|
|
515
|
+
);
|
|
516
|
+
expect(exitSpy).not.toHaveBeenCalledWith(1);
|
|
517
|
+
|
|
518
|
+
logSpy.mockRestore();
|
|
519
|
+
exitSpy.mockRestore();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('DPB-09: openclaw-cli flag overrides file config mode (config=local, flag=gateway → runtimeMode=gateway)', async () => {
|
|
523
|
+
mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
|
|
524
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
525
|
+
result: {
|
|
526
|
+
runtimeKind: 'openclaw-cli',
|
|
527
|
+
openclawMode: 'local',
|
|
528
|
+
timeoutMs: 300000,
|
|
529
|
+
agentId: 'main',
|
|
530
|
+
},
|
|
531
|
+
legacyWarnings: [],
|
|
532
|
+
configSource: '.pd/config.yaml',
|
|
533
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
537
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
538
|
+
|
|
539
|
+
await handlePainRetry({
|
|
540
|
+
painId: 'test-pain-1',
|
|
541
|
+
workspace: '/tmp/fake-workspace',
|
|
542
|
+
runtime: 'openclaw-cli',
|
|
543
|
+
openclawGateway: true,
|
|
544
|
+
json: true,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const OpenClawCliMock = vi.mocked(
|
|
548
|
+
await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
|
|
549
|
+
);
|
|
550
|
+
expect(OpenClawCliMock).toHaveBeenCalledWith(
|
|
551
|
+
expect.objectContaining({ runtimeMode: 'gateway' }),
|
|
552
|
+
);
|
|
553
|
+
expect(exitSpy).not.toHaveBeenCalledWith(1);
|
|
554
|
+
|
|
555
|
+
logSpy.mockRestore();
|
|
556
|
+
exitSpy.mockRestore();
|
|
557
|
+
});
|
|
443
558
|
});
|
|
444
559
|
|
|
445
560
|
describe('pd pain retry — success paths', () => {
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-393: Runtime config unification tests
|
|
3
|
+
*
|
|
4
|
+
* Validates that all MVP mainline execution paths (probe, run-once, diagnose,
|
|
5
|
+
* pain-retry) read from .pd/config.yaml, NOT from .state/workflows.yaml.
|
|
6
|
+
*
|
|
7
|
+
* ERR refs:
|
|
8
|
+
* - EP-02: production path wiring — tests exercise real production entry points
|
|
9
|
+
* - EP-03: fail loud — no silent fallback
|
|
10
|
+
* - EP-07: runtime state source alignment — doctor/probe/run-once agree
|
|
11
|
+
* - EP-09: test reality gap — production schema fixtures
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as yaml from 'js-yaml';
|
|
19
|
+
import {
|
|
20
|
+
assertMainlineContract,
|
|
21
|
+
type MainlineSnapshot,
|
|
22
|
+
} from '@principles/core/runtime-v2';
|
|
23
|
+
|
|
24
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function mkTmpDir(prefix = 'pri-393-'): string {
|
|
27
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rmTmpDir(dir: string): void {
|
|
31
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeConfigYaml(workspaceDir: string, content: object): void {
|
|
35
|
+
const pdDir = path.join(workspaceDir, '.pd');
|
|
36
|
+
fs.mkdirSync(pdDir, { recursive: true });
|
|
37
|
+
fs.writeFileSync(
|
|
38
|
+
path.join(pdDir, 'config.yaml'),
|
|
39
|
+
yaml.dump(content, { lineWidth: -1 }),
|
|
40
|
+
'utf8',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeLegacyWorkflowsYaml(workspaceDir: string, content: string): void {
|
|
45
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
46
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(stateDir, 'workflows.yaml'), content, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Minimal valid .pd/config.yaml with a pi-ai runtime profile for diagnostician. */
|
|
51
|
+
function makeValidConfigYaml(overrides?: { provider?: string; model?: string }): object {
|
|
52
|
+
return {
|
|
53
|
+
version: 1,
|
|
54
|
+
features: {
|
|
55
|
+
prompt: { enabled: true, category: 'core' },
|
|
56
|
+
correction_observer: { enabled: false, category: 'quiet' },
|
|
57
|
+
},
|
|
58
|
+
runtimeProfiles: {
|
|
59
|
+
lmstudio: {
|
|
60
|
+
type: 'pi-ai',
|
|
61
|
+
provider: overrides?.provider ?? 'lmstudio',
|
|
62
|
+
model: overrides?.model ?? 'local-model',
|
|
63
|
+
apiKeyEnv: 'LMSTUDIO_API_KEY',
|
|
64
|
+
baseUrl: 'http://localhost:1234/v1',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
internalAgents: {
|
|
68
|
+
defaultRuntime: 'lmstudio',
|
|
69
|
+
agents: {
|
|
70
|
+
diagnostician: {
|
|
71
|
+
enabled: true,
|
|
72
|
+
runtimeProfile: 'lmstudio',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('PRI-393: runtime config unification', () => {
|
|
82
|
+
let tmpDir: string;
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
tmpDir = mkTmpDir();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
rmTmpDir(tmpDir);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('Guard: legacy resolveRuntimeConfig not imported by production commands', () => {
|
|
93
|
+
it('production command source files do NOT import legacy resolveRuntimeConfig', () => {
|
|
94
|
+
// Read the source of each production command and verify the import
|
|
95
|
+
const commandFiles = [
|
|
96
|
+
'packages/pd-cli/src/commands/runtime.ts',
|
|
97
|
+
'packages/pd-cli/src/commands/runtime-internalization-run-once.ts',
|
|
98
|
+
'packages/pd-cli/src/commands/diagnose.ts',
|
|
99
|
+
'packages/pd-cli/src/commands/pain-retry.ts',
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
for (const file of commandFiles) {
|
|
103
|
+
const fullPath = path.resolve(file);
|
|
104
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
105
|
+
const source = fs.readFileSync(fullPath, 'utf8');
|
|
106
|
+
|
|
107
|
+
// Check that resolveRuntimeConfig is NOT imported from @principles/core/runtime-v2
|
|
108
|
+
// (it may appear in comments or as resolveRuntimeConfigFromPdConfig)
|
|
109
|
+
// Match the full import block (handles multi-line imports)
|
|
110
|
+
const importPattern = /import\s*[\s\S]*?@principles\/core\/runtime-v2['";]/g;
|
|
111
|
+
const importBlocks: string[] = [];
|
|
112
|
+
let match;
|
|
113
|
+
while ((match = importPattern.exec(source)) !== null) {
|
|
114
|
+
importBlocks.push(match[0]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const block of importBlocks) {
|
|
118
|
+
// Allow resolveRuntimeConfigFromPdConfig but NOT bare resolveRuntimeConfig
|
|
119
|
+
if (block.includes('resolveRuntimeConfig') && !block.includes('resolveRuntimeConfigFromPdConfig')) {
|
|
120
|
+
// This is the legacy import — fail
|
|
121
|
+
expect.fail(
|
|
122
|
+
`${file} still imports legacy resolveRuntimeConfig from @principles/core/runtime-v2. ` +
|
|
123
|
+
`Use resolveRuntimeFromPdConfig() from services/resolve-runtime-from-pd-config.ts instead.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('Config source alignment via mainline contract', () => {
|
|
132
|
+
function makeBaseSnapshot(readiness: Partial<MainlineSnapshot['readiness']>): MainlineSnapshot {
|
|
133
|
+
return {
|
|
134
|
+
readiness: {
|
|
135
|
+
configDoctorProfile: null,
|
|
136
|
+
runtimeProbeProfile: null,
|
|
137
|
+
configSource: '.pd/config.yaml',
|
|
138
|
+
probeConfigSource: '.pd/config.yaml',
|
|
139
|
+
diagnosticianReady: true,
|
|
140
|
+
...readiness,
|
|
141
|
+
},
|
|
142
|
+
chain: {
|
|
143
|
+
painId: null,
|
|
144
|
+
diagnosisTask: null,
|
|
145
|
+
diagnosticianArtifact: null,
|
|
146
|
+
candidate: null,
|
|
147
|
+
dreamerTask: null,
|
|
148
|
+
dreamerContext: null,
|
|
149
|
+
successor: null,
|
|
150
|
+
principle: null,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('violation: doctor and probe use different config sources (drift)', () => {
|
|
156
|
+
const snapshot = makeBaseSnapshot({
|
|
157
|
+
configDoctorProfile: 'pi-ai.lmstudio',
|
|
158
|
+
runtimeProbeProfile: 'pi-ai.sensenova-cn',
|
|
159
|
+
configSource: '.pd/config.yaml',
|
|
160
|
+
probeConfigSource: '.state/workflows.yaml',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const verdict = assertMainlineContract(snapshot);
|
|
164
|
+
const alignmentStage = verdict.stages.find((s) => s.stage === 'config_source_alignment');
|
|
165
|
+
|
|
166
|
+
expect(alignmentStage).toBeDefined();
|
|
167
|
+
expect(alignmentStage!.status).toBe('violation');
|
|
168
|
+
expect(alignmentStage!.reason).toContain('drift');
|
|
169
|
+
expect(alignmentStage!.nextAction).toContain('.pd/config.yaml');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('violation: profiles match but probe reads from workflows.yaml (coincidental)', () => {
|
|
173
|
+
const snapshot = makeBaseSnapshot({
|
|
174
|
+
configDoctorProfile: 'pi-ai.lmstudio',
|
|
175
|
+
runtimeProbeProfile: 'pi-ai.lmstudio',
|
|
176
|
+
configSource: '.pd/config.yaml',
|
|
177
|
+
probeConfigSource: '.state/workflows.yaml',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const verdict = assertMainlineContract(snapshot);
|
|
181
|
+
const alignmentStage = verdict.stages.find((s) => s.stage === 'config_source_alignment');
|
|
182
|
+
|
|
183
|
+
expect(alignmentStage).toBeDefined();
|
|
184
|
+
expect(alignmentStage!.status).toBe('violation');
|
|
185
|
+
expect(alignmentStage!.reason).toContain('coincidental');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('ok: doctor and probe agree on same profile from .pd/config.yaml', () => {
|
|
189
|
+
const snapshot = makeBaseSnapshot({
|
|
190
|
+
configDoctorProfile: 'pi-ai.lmstudio',
|
|
191
|
+
runtimeProbeProfile: 'pi-ai.lmstudio',
|
|
192
|
+
configSource: '.pd/config.yaml',
|
|
193
|
+
probeConfigSource: '.pd/config.yaml',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const verdict = assertMainlineContract(snapshot);
|
|
197
|
+
const alignmentStage = verdict.stages.find((s) => s.stage === 'config_source_alignment');
|
|
198
|
+
|
|
199
|
+
expect(alignmentStage).toBeDefined();
|
|
200
|
+
expect(alignmentStage!.status).toBe('ok');
|
|
201
|
+
expect(alignmentStage!.reason).toContain('.pd/config.yaml');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('resolveRuntimeFromPdConfig reads .pd/config.yaml', () => {
|
|
206
|
+
it('resolves pi-ai config from .pd/config.yaml', async () => {
|
|
207
|
+
writeConfigYaml(tmpDir, makeValidConfigYaml());
|
|
208
|
+
|
|
209
|
+
// Dynamically import to avoid module resolution issues
|
|
210
|
+
const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
|
|
211
|
+
const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
|
|
212
|
+
|
|
213
|
+
expect(resolved.configSource).toBe('.pd/config.yaml');
|
|
214
|
+
expect(resolved.result).toBeDefined();
|
|
215
|
+
// Should not be an error when config is valid
|
|
216
|
+
const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
|
|
217
|
+
expect(isErr(resolved.result)).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('ignores conflicting .state/workflows.yaml when .pd/config.yaml exists', async () => {
|
|
221
|
+
// Write .pd/config.yaml with lmstudio
|
|
222
|
+
writeConfigYaml(tmpDir, makeValidConfigYaml({ provider: 'lmstudio', model: 'local-model' }));
|
|
223
|
+
|
|
224
|
+
// Write conflicting .state/workflows.yaml
|
|
225
|
+
writeLegacyWorkflowsYaml(tmpDir, `version: '1'
|
|
226
|
+
funnels:
|
|
227
|
+
- workflowId: pd-runtime-v2-diagnosis
|
|
228
|
+
stages: []
|
|
229
|
+
policy:
|
|
230
|
+
runtimeKind: pi-ai
|
|
231
|
+
provider: sensenova-cn
|
|
232
|
+
model: deepseek-v4-flash
|
|
233
|
+
apiKeyEnv: SENSENOVA_API_KEY
|
|
234
|
+
`);
|
|
235
|
+
|
|
236
|
+
const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
|
|
237
|
+
const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
|
|
238
|
+
|
|
239
|
+
// Should have legacy warning
|
|
240
|
+
expect(resolved.legacyWarnings.length).toBeGreaterThan(0);
|
|
241
|
+
expect(resolved.legacyWarnings[0]).toContain('workflows.yaml');
|
|
242
|
+
|
|
243
|
+
// Should resolve from .pd/config.yaml, NOT workflows.yaml
|
|
244
|
+
const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
|
|
245
|
+
if (!isErr(resolved.result)) {
|
|
246
|
+
expect(resolved.result.provider).not.toBe('sensenova-cn');
|
|
247
|
+
}
|
|
248
|
+
expect(resolved.configSource).toBe('.pd/config.yaml');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('fail loud when .pd/config.yaml is malformed', async () => {
|
|
252
|
+
const pdDir = path.join(tmpDir, '.pd');
|
|
253
|
+
fs.mkdirSync(pdDir, { recursive: true });
|
|
254
|
+
fs.writeFileSync(path.join(pdDir, 'config.yaml'), 'version: [unterminated', 'utf8');
|
|
255
|
+
|
|
256
|
+
const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
|
|
257
|
+
const { isRuntimeConfigError: isErr } = await import('@principles/core/runtime-v2');
|
|
258
|
+
const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
|
|
259
|
+
|
|
260
|
+
// Malformed config must produce a RuntimeConfigError — never fall back to defaults
|
|
261
|
+
expect(resolved.configLoadResult.ok).toBe(false);
|
|
262
|
+
expect(resolved.configSource).toBe('.pd/config.yaml');
|
|
263
|
+
expect(isErr(resolved.result)).toBe(true);
|
|
264
|
+
if (isErr(resolved.result)) {
|
|
265
|
+
expect(resolved.result.reason).toContain('config_malformed');
|
|
266
|
+
expect(resolved.result.nextAction).toBeTruthy();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('JSON output purity', () => {
|
|
272
|
+
it('resolveRuntimeFromPdConfig result serializes to valid JSON', async () => {
|
|
273
|
+
writeConfigYaml(tmpDir, makeValidConfigYaml());
|
|
274
|
+
|
|
275
|
+
const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
|
|
276
|
+
const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
|
|
277
|
+
|
|
278
|
+
const jsonStr = JSON.stringify(resolved.result);
|
|
279
|
+
const parsed = JSON.parse(jsonStr);
|
|
280
|
+
expect(parsed).toBeDefined();
|
|
281
|
+
expect(typeof parsed).toBe('object');
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|