@principles/pd-cli 1.101.0 → 1.103.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-integrity.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-integrity.js +40 -1
- package/dist/commands/runtime-internalization-integrity.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/services/mainline-snapshot-assembler.d.ts +35 -0
- package/dist/services/mainline-snapshot-assembler.d.ts.map +1 -0
- package/dist/services/mainline-snapshot-assembler.js +399 -0
- package/dist/services/mainline-snapshot-assembler.js.map +1 -0
- 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-integrity.ts +40 -1
- package/src/commands/runtime-internalization-run-once.ts +10 -9
- package/src/commands/runtime.ts +96 -24
- package/src/services/mainline-snapshot-assembler.ts +544 -0
- 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.test.ts +37 -0
- package/tests/commands/runtime-internalization-run-once.test.ts +59 -53
- package/tests/commands/runtime.test.ts +124 -1
- package/tests/services/mainline-snapshot-assembler.test.ts +425 -0
|
@@ -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
|
+
});
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import * as path from 'path';
|
|
2
3
|
|
|
3
4
|
const mockCheck = vi.hoisted(() => vi.fn());
|
|
5
|
+
const mockAssemble = vi.hoisted(() => vi.fn());
|
|
4
6
|
|
|
5
7
|
vi.mock('../../src/resolve-workspace.js', () => ({
|
|
6
8
|
resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
|
|
7
9
|
}));
|
|
8
10
|
|
|
11
|
+
vi.mock('../../src/services/mainline-snapshot-assembler.js', () => ({
|
|
12
|
+
assembleMainlineSnapshot: mockAssemble,
|
|
13
|
+
assertMainlineContract: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
9
16
|
vi.mock('@principles/core/runtime-v2', () => ({
|
|
10
17
|
InternalizationChainIntegrityReadModel: vi.fn().mockImplementation(function () {
|
|
11
18
|
return { check: mockCheck };
|
|
@@ -14,9 +21,32 @@ vi.mock('@principles/core/runtime-v2', () => ({
|
|
|
14
21
|
}));
|
|
15
22
|
|
|
16
23
|
import { handleRuntimeInternalizationIntegrity } from '../../src/commands/runtime-internalization-integrity.js';
|
|
24
|
+
import { InternalizationChainIntegrityReadModel } from '@principles/core/runtime-v2';
|
|
17
25
|
|
|
18
26
|
const WS = '/fake/workspace';
|
|
19
27
|
|
|
28
|
+
function fakeSnapshot() {
|
|
29
|
+
return {
|
|
30
|
+
readiness: {
|
|
31
|
+
configDoctorProfile: 'openclaw.default',
|
|
32
|
+
runtimeProbeProfile: 'openclaw.default',
|
|
33
|
+
configSource: '.pd/config.yaml',
|
|
34
|
+
probeConfigSource: '.pd/config.yaml',
|
|
35
|
+
diagnosticianReady: true,
|
|
36
|
+
},
|
|
37
|
+
chain: {
|
|
38
|
+
painId: 'pain-test',
|
|
39
|
+
diagnosisTask: null,
|
|
40
|
+
diagnosticianArtifact: null,
|
|
41
|
+
candidate: null,
|
|
42
|
+
dreamerTask: null,
|
|
43
|
+
dreamerContext: null,
|
|
44
|
+
successor: null,
|
|
45
|
+
principle: null,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
20
50
|
function okResult() {
|
|
21
51
|
return {
|
|
22
52
|
overallStatus: 'ok' as const,
|
|
@@ -59,6 +89,7 @@ describe('handleRuntimeInternalizationIntegrity', () => {
|
|
|
59
89
|
|
|
60
90
|
beforeEach(() => {
|
|
61
91
|
vi.clearAllMocks();
|
|
92
|
+
mockAssemble.mockResolvedValue({ snapshot: fakeSnapshot(), warnings: [], resolvedPainId: 'pain-test' });
|
|
62
93
|
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
63
94
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
64
95
|
});
|
|
@@ -68,6 +99,12 @@ describe('handleRuntimeInternalizationIntegrity', () => {
|
|
|
68
99
|
|
|
69
100
|
await handleRuntimeInternalizationIntegrity({ workspace: WS, json: true });
|
|
70
101
|
|
|
102
|
+
const resolvedWorkspace = path.resolve(WS);
|
|
103
|
+
expect(mockAssemble).toHaveBeenCalledWith({ workspaceDir: resolvedWorkspace });
|
|
104
|
+
const modelCallArgs = (InternalizationChainIntegrityReadModel as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
105
|
+
expect(modelCallArgs.workspaceDir).toBe(resolvedWorkspace);
|
|
106
|
+
expect(modelCallArgs.mainlineSnapshot).toEqual(fakeSnapshot());
|
|
107
|
+
|
|
71
108
|
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
|
|
72
109
|
expect(jsonOutput.overallStatus).toBe('ok');
|
|
73
110
|
expect(jsonOutput.brokenLinks).toEqual([]);
|
|
@@ -20,6 +20,27 @@ vi.mock('../../src/resolve-workspace.js', () => ({
|
|
|
20
20
|
resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
|
+
const { mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
|
|
24
|
+
const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
|
|
25
|
+
result: {
|
|
26
|
+
runtimeKind: 'pi-ai',
|
|
27
|
+
provider: 'test-provider',
|
|
28
|
+
model: 'test-model',
|
|
29
|
+
apiKeyEnv: 'TEST_API_KEY',
|
|
30
|
+
timeoutMs: 300_000,
|
|
31
|
+
agentId: 'main',
|
|
32
|
+
},
|
|
33
|
+
legacyWarnings: [],
|
|
34
|
+
configSource: '.pd/config.yaml',
|
|
35
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
36
|
+
});
|
|
37
|
+
return { mockResolveRuntimeFromPdConfig };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
|
|
41
|
+
resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
|
|
42
|
+
}));
|
|
43
|
+
|
|
23
44
|
vi.mock('@principles/core/runtime-v2', () => ({
|
|
24
45
|
RuntimeStateManager: vi.fn().mockImplementation(function () {
|
|
25
46
|
return {
|
|
@@ -120,8 +141,9 @@ vi.mock('@principles/core/runtime-v2', () => ({
|
|
|
120
141
|
model: 'test-model',
|
|
121
142
|
apiKeyEnv: 'TEST_API_KEY',
|
|
122
143
|
}),
|
|
123
|
-
isRuntimeConfigError: vi.fn().
|
|
144
|
+
isRuntimeConfigError: vi.fn().mockImplementation((result: unknown) => result != null && typeof result === 'object' && Object.hasOwn(result, 'reason') && !Object.hasOwn(result, 'runtimeKind')),
|
|
124
145
|
validateRuntimeConfig: vi.fn(),
|
|
146
|
+
resolveRuntimeConfigFromPdConfig: vi.fn().mockReturnValue({ runtimeKind: 'pi-ai', provider: 'test-provider', model: 'test-model', apiKeyEnv: 'TEST_API_KEY', timeoutMs: 300_000, agentId: 'main' }),
|
|
125
147
|
resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
|
|
126
148
|
}));
|
|
127
149
|
|
|
@@ -381,12 +403,16 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
381
403
|
});
|
|
382
404
|
|
|
383
405
|
it('--runtime openclaw-cli resolves OpenClawCliRuntimeAdapter', async () => {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
406
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
407
|
+
result: {
|
|
408
|
+
runtimeKind: 'openclaw-cli',
|
|
409
|
+
openclawMode: 'local',
|
|
410
|
+
timeoutMs: 300_000,
|
|
411
|
+
agentId: 'main',
|
|
412
|
+
},
|
|
413
|
+
legacyWarnings: [],
|
|
414
|
+
configSource: '.pd/config.yaml',
|
|
415
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
390
416
|
});
|
|
391
417
|
|
|
392
418
|
mockWakeOnce.mockResolvedValue({
|
|
@@ -412,31 +438,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
412
438
|
expect(OpenClawMock).toHaveBeenCalled();
|
|
413
439
|
});
|
|
414
440
|
|
|
415
|
-
it('--runtime config resolves adapter from
|
|
416
|
-
mockWakeOnce.mockResolvedValue({
|
|
417
|
-
decision: 'would_lease',
|
|
418
|
-
taskId: 'task-dreamer-008',
|
|
419
|
-
taskKind: 'dreamer',
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
mockRun.mockResolvedValue({
|
|
423
|
-
status: 'succeeded',
|
|
424
|
-
taskId: 'task-dreamer-008',
|
|
425
|
-
runId: 'run-008',
|
|
426
|
-
artifactId: 'pi-art-task-dreamer-008-run-008',
|
|
427
|
-
resultRef: 'dreamer://run-008',
|
|
428
|
-
attemptCount: 1,
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
await handleRuntimeInternalizationRunOnce({ workspace: WS, runtime: 'config', json: true });
|
|
432
|
-
|
|
433
|
-
const ResolveConfigMock = vi.mocked(
|
|
434
|
-
await import('@principles/core/runtime-v2').then(m => m.resolveRuntimeConfig),
|
|
435
|
-
);
|
|
436
|
-
expect(ResolveConfigMock).toHaveBeenCalled();
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
it('--runtime config reads from workspaceDir/.state (not .pd)', async () => {
|
|
441
|
+
it('--runtime config resolves adapter from .pd/config.yaml with workspace path (PRI-393)', async () => {
|
|
440
442
|
mockWakeOnce.mockResolvedValue({
|
|
441
443
|
decision: 'would_lease',
|
|
442
444
|
taskId: 'task-dreamer-009',
|
|
@@ -455,12 +457,10 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
455
457
|
const customWs = '/tmp/test-workspace';
|
|
456
458
|
await handleRuntimeInternalizationRunOnce({ workspace: customWs, runtime: 'config', json: true });
|
|
457
459
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
+
// PRI-393: verify resolveRuntimeFromPdConfig was called with workspace dir
|
|
461
|
+
expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith(
|
|
462
|
+
expect.stringContaining('test-workspace'),
|
|
460
463
|
);
|
|
461
|
-
const resolvedWorkspace = path.resolve(customWs);
|
|
462
|
-
const expectedStateDir = path.join(resolvedWorkspace, '.state');
|
|
463
|
-
expect(ResolveConfigMock).toHaveBeenCalledWith(expectedStateDir, { requestedRuntimeKind: 'config' });
|
|
464
464
|
});
|
|
465
465
|
|
|
466
466
|
it('--runner philosopher dispatches PhilosopherRunner', async () => {
|
|
@@ -810,7 +810,7 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
810
810
|
expect(output.timeoutSource).toBe('runner_poll');
|
|
811
811
|
});
|
|
812
812
|
|
|
813
|
-
it('--timeout-ms overrides
|
|
813
|
+
it('--timeout-ms overrides .pd/config.yaml timeoutMs for PiAiRuntimeAdapter (PRI-393)', async () => {
|
|
814
814
|
mockWakeOnce.mockResolvedValue({
|
|
815
815
|
decision: 'would_lease',
|
|
816
816
|
taskId: 'task-dreamer-ov',
|
|
@@ -1258,14 +1258,17 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
1258
1258
|
});
|
|
1259
1259
|
|
|
1260
1260
|
it('--runtime config with missing config outputs structured JSON error', async () => {
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1261
|
+
// PRI-393: mock resolveRuntimeFromPdConfig to return config error
|
|
1262
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
1263
|
+
result: {
|
|
1264
|
+
reason: 'explicit_config_missing',
|
|
1265
|
+
message: 'runtime=config requested but no .pd/config.yaml runtime binding found',
|
|
1266
|
+
nextAction: 'Add runtime binding to .pd/config.yaml',
|
|
1267
|
+
},
|
|
1268
|
+
legacyWarnings: [],
|
|
1269
|
+
configSource: '.pd/config.yaml',
|
|
1270
|
+
configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
1267
1271
|
});
|
|
1268
|
-
vi.mocked(isRuntimeConfigError).mockReturnValue(true);
|
|
1269
1272
|
|
|
1270
1273
|
mockWakeOnce.mockResolvedValue({
|
|
1271
1274
|
decision: 'would_lease',
|
|
@@ -1283,14 +1286,17 @@ describe('handleRuntimeInternalizationRunOnce', () => {
|
|
|
1283
1286
|
});
|
|
1284
1287
|
|
|
1285
1288
|
it('--runtime config with missing config outputs text error', async () => {
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1289
|
+
// PRI-393: mock resolveRuntimeFromPdConfig to return config error
|
|
1290
|
+
mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
|
|
1291
|
+
result: {
|
|
1292
|
+
reason: 'explicit_config_missing',
|
|
1293
|
+
message: 'runtime=config requested but no .pd/config.yaml runtime binding found',
|
|
1294
|
+
nextAction: 'Add runtime binding to .pd/config.yaml',
|
|
1295
|
+
},
|
|
1296
|
+
legacyWarnings: [],
|
|
1297
|
+
configSource: '.pd/config.yaml',
|
|
1298
|
+
configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
1299
|
+
});
|
|
1294
1300
|
|
|
1295
1301
|
mockWakeOnce.mockResolvedValue({
|
|
1296
1302
|
decision: 'would_lease',
|
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { handleRuntimeProbe, type RuntimeProbeOptions } from '../../src/commands/runtime.js';
|
|
3
3
|
|
|
4
|
-
// Mock
|
|
4
|
+
// Mock resolveRuntimeWithOverrides
|
|
5
|
+
const { mockResolveRuntimeWithOverrides } = vi.hoisted(() => {
|
|
6
|
+
const fn = vi.fn().mockReturnValue({
|
|
7
|
+
result: {
|
|
8
|
+
runtimeKind: 'pi-ai',
|
|
9
|
+
provider: 'test-provider',
|
|
10
|
+
model: 'test-model',
|
|
11
|
+
apiKeyEnv: 'TEST_API_KEY',
|
|
12
|
+
maxRetries: 2,
|
|
13
|
+
timeoutMs: 180_000,
|
|
14
|
+
},
|
|
15
|
+
mergedConfig: {
|
|
16
|
+
runtimeKind: 'pi-ai',
|
|
17
|
+
provider: 'test-provider',
|
|
18
|
+
model: 'test-model',
|
|
19
|
+
apiKeyEnv: 'TEST_API_KEY',
|
|
20
|
+
maxRetries: 2,
|
|
21
|
+
timeoutMs: 180_000,
|
|
22
|
+
},
|
|
23
|
+
legacyWarnings: [],
|
|
24
|
+
configSource: '.pd/config.yaml',
|
|
25
|
+
configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
|
|
26
|
+
});
|
|
27
|
+
return { mockResolveRuntimeWithOverrides: fn };
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
|
|
31
|
+
resolveRuntimeWithOverrides: mockResolveRuntimeWithOverrides,
|
|
32
|
+
}));
|
|
33
|
+
|
|
5
34
|
vi.mock('@principles/core/runtime-v2', () => ({
|
|
6
35
|
probeRuntime: vi.fn().mockResolvedValue({
|
|
7
36
|
runtimeKind: 'openclaw-cli',
|
|
@@ -260,4 +289,98 @@ describe('pd runtime probe', () => {
|
|
|
260
289
|
consoleErrorSpy.mockRestore();
|
|
261
290
|
exitSpy.mockRestore();
|
|
262
291
|
});
|
|
292
|
+
|
|
293
|
+
// ── pi-ai probe: maxRetries backfill from .pd/config.yaml ───────────────
|
|
294
|
+
it('PRI-393: pi-ai probe reads maxRetries from .pd/config.yaml when --maxRetries not passed', async () => {
|
|
295
|
+
process.env.TEST_API_KEY = 'test-value';
|
|
296
|
+
const { probeRuntime } = await import('@principles/core/runtime-v2');
|
|
297
|
+
vi.mocked(probeRuntime).mockResolvedValue({
|
|
298
|
+
runtimeKind: 'pi-ai',
|
|
299
|
+
provider: 'test-provider',
|
|
300
|
+
model: 'test-model',
|
|
301
|
+
health: { healthy: true, degraded: false, warnings: [], lastCheckedAt: '2026-06-14T00:00:00.000Z' },
|
|
302
|
+
capabilities: {},
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
306
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
307
|
+
|
|
308
|
+
// mockResolveRuntimeWithOverrides returns maxRetries: 2
|
|
309
|
+
// No --maxRetries CLI flag
|
|
310
|
+
await handleRuntimeProbe({
|
|
311
|
+
runtime: 'pi-ai',
|
|
312
|
+
provider: 'test-provider',
|
|
313
|
+
model: 'test-model',
|
|
314
|
+
apiKeyEnv: 'TEST_API_KEY',
|
|
315
|
+
workspace: '/tmp/ws',
|
|
316
|
+
json: true,
|
|
317
|
+
} as RuntimeProbeOptions);
|
|
318
|
+
|
|
319
|
+
expect(vi.mocked(probeRuntime)).toHaveBeenCalledWith(
|
|
320
|
+
expect.objectContaining({ maxRetries: 2 }),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
delete process.env.TEST_API_KEY;
|
|
324
|
+
consoleSpy.mockRestore();
|
|
325
|
+
exitSpy.mockRestore();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('PRI-393: CLI --maxRetries overrides .pd/config.yaml maxRetries', async () => {
|
|
329
|
+
process.env.TEST_API_KEY = 'test-value';
|
|
330
|
+
const { probeRuntime } = await import('@principles/core/runtime-v2');
|
|
331
|
+
vi.mocked(probeRuntime).mockResolvedValue({
|
|
332
|
+
runtimeKind: 'pi-ai',
|
|
333
|
+
provider: 'test-provider',
|
|
334
|
+
model: 'test-model',
|
|
335
|
+
health: { healthy: true, degraded: false, warnings: [], lastCheckedAt: '2026-06-14T00:00:00.000Z' },
|
|
336
|
+
capabilities: {},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
340
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
341
|
+
|
|
342
|
+
await handleRuntimeProbe({
|
|
343
|
+
runtime: 'pi-ai',
|
|
344
|
+
provider: 'test-provider',
|
|
345
|
+
model: 'test-model',
|
|
346
|
+
apiKeyEnv: 'TEST_API_KEY',
|
|
347
|
+
maxRetries: 5,
|
|
348
|
+
workspace: '/tmp/ws',
|
|
349
|
+
json: true,
|
|
350
|
+
} as RuntimeProbeOptions);
|
|
351
|
+
|
|
352
|
+
expect(vi.mocked(probeRuntime)).toHaveBeenCalledWith(
|
|
353
|
+
expect.objectContaining({ maxRetries: 5 }),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
delete process.env.TEST_API_KEY;
|
|
357
|
+
consoleSpy.mockRestore();
|
|
358
|
+
exitSpy.mockRestore();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('PRI-393: env var missing → process.exit(1) and does NOT call probeRuntime', async () => {
|
|
362
|
+
const { probeRuntime } = await import('@principles/core/runtime-v2');
|
|
363
|
+
vi.mocked(probeRuntime).mockClear();
|
|
364
|
+
|
|
365
|
+
// Ensure NONEXISTENT_VAR is NOT set
|
|
366
|
+
delete process.env.NONEXISTENT_VAR;
|
|
367
|
+
|
|
368
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
369
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
370
|
+
|
|
371
|
+
await handleRuntimeProbe({
|
|
372
|
+
runtime: 'pi-ai',
|
|
373
|
+
provider: 'test-provider',
|
|
374
|
+
model: 'test-model',
|
|
375
|
+
apiKeyEnv: 'NONEXISTENT_VAR',
|
|
376
|
+
json: true,
|
|
377
|
+
} as RuntimeProbeOptions);
|
|
378
|
+
|
|
379
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('NONEXISTENT_VAR'));
|
|
380
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
381
|
+
expect(vi.mocked(probeRuntime)).not.toHaveBeenCalled();
|
|
382
|
+
|
|
383
|
+
consoleErrorSpy.mockRestore();
|
|
384
|
+
exitSpy.mockRestore();
|
|
385
|
+
});
|
|
263
386
|
});
|