@principles/pd-cli 1.101.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.
Files changed (28) hide show
  1. package/dist/commands/diagnose.js +27 -27
  2. package/dist/commands/diagnose.js.map +1 -1
  3. package/dist/commands/pain-retry.d.ts.map +1 -1
  4. package/dist/commands/pain-retry.js +22 -27
  5. package/dist/commands/pain-retry.js.map +1 -1
  6. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  7. package/dist/commands/runtime-internalization-run-once.js +11 -9
  8. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  9. package/dist/commands/runtime.d.ts +1 -1
  10. package/dist/commands/runtime.d.ts.map +1 -1
  11. package/dist/commands/runtime.js +92 -25
  12. package/dist/commands/runtime.js.map +1 -1
  13. package/dist/services/resolve-runtime-from-pd-config.d.ts +59 -0
  14. package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -0
  15. package/dist/services/resolve-runtime-from-pd-config.js +96 -0
  16. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -0
  17. package/package.json +1 -1
  18. package/src/commands/diagnose.ts +26 -26
  19. package/src/commands/pain-retry.ts +21 -25
  20. package/src/commands/runtime-internalization-run-once.ts +10 -9
  21. package/src/commands/runtime.ts +96 -24
  22. package/src/services/resolve-runtime-from-pd-config.ts +142 -0
  23. package/tests/commands/console-launcher-edge-cases.test.ts +14 -47
  24. package/tests/commands/diagnose.test.ts +91 -39
  25. package/tests/commands/pain-retry.test.ts +130 -15
  26. package/tests/commands/pri-393-runtime-config-unification.test.ts +284 -0
  27. package/tests/commands/runtime-internalization-run-once.test.ts +59 -53
  28. package/tests/commands/runtime.test.ts +124 -1
@@ -20,13 +20,13 @@ import {
20
20
  TestDoubleRuntimeAdapter,
21
21
  PiAiRuntimeAdapter,
22
22
  OpenClawCliRuntimeAdapter,
23
- resolveRuntimeConfig,
24
23
  isRuntimeConfigError,
25
24
  validateRuntimeConfig,
26
25
  } from '@principles/core/runtime-v2';
27
26
  import type { WakeOnceResult, DreamerRunnerResult, PhilosopherRunnerResult, ScribeRunnerResult, ArtificerRunnerResult, EvaluatorRunnerResult, RolloutReviewerRunnerResult, TrainerRunnerResult, PDRuntimeAdapter, PeerRunnerKind, OutputLanguage } from '@principles/core/runtime-v2';
28
27
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
29
28
  import { readOutputLanguageFromWorkspace } from '../config-reader.js';
29
+ import { resolveRuntimeFromPdConfig } from '../services/resolve-runtime-from-pd-config.js';
30
30
 
31
31
  interface RunOnceOptions {
32
32
  workspace?: string;
@@ -472,19 +472,20 @@ function resolveRuntimeAdapter(opts: ResolveAdapterOptions): PDRuntimeAdapter {
472
472
  });
473
473
  }
474
474
 
475
- const stateDir = path.join(opts.workspaceDir, '.state');
476
- const configResult = resolveRuntimeConfig(stateDir, { requestedRuntimeKind: opts.runtimeKind });
475
+ // PRI-393: resolve runtime from .pd/config.yaml (not .state/workflows.yaml)
476
+ const resolved = resolveRuntimeFromPdConfig(opts.workspaceDir);
477
+ const configResult = resolved.result;
477
478
 
478
479
  if (isRuntimeConfigError(configResult)) {
479
480
  throw new ConfigResolutionError(
480
- `Config resolution failed: ${configResult.reason}. ` +
481
+ `Config resolution from .pd/config.yaml failed: ${configResult.reason}. ` +
481
482
  `${configResult.message}. nextAction: ${configResult.nextAction}`,
482
483
  );
483
484
  }
484
485
 
485
486
  if (opts.runtimeKind === 'pi-ai' || (opts.runtimeKind === 'config' && configResult.runtimeKind === 'pi-ai')) {
486
487
  validateRuntimeConfig(configResult);
487
- // CLI --timeout-ms overrides workflows.yaml timeoutMs
488
+ // CLI --timeout-ms overrides config timeoutMs
488
489
  const adapterTimeoutMs = opts.timeoutMs ?? configResult.timeoutMs;
489
490
  return new PiAiRuntimeAdapter({
490
491
  provider: String(configResult.provider),
@@ -502,8 +503,8 @@ function resolveRuntimeAdapter(opts: ResolveAdapterOptions): PDRuntimeAdapter {
502
503
  if (!openclawMode) {
503
504
  throw new ConfigResolutionError(
504
505
  `runtimeKind 'openclaw-cli' requires openclawMode. ` +
505
- `Provide --openclaw-local or --openclaw-gateway, or set openclawMode in workflows.yaml. ` +
506
- `nextAction: Add openclawMode: local|gateway to your funnel policy or use CLI flags.`,
506
+ `Provide --openclaw-local or --openclaw-gateway, or set openclawMode in .pd/config.yaml. ` +
507
+ `nextAction: Add openclawMode: local|gateway to your .pd/config.yaml runtime profile or use CLI flags.`,
507
508
  );
508
509
  }
509
510
  return new OpenClawCliRuntimeAdapter({
@@ -533,7 +534,7 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
533
534
  if (runtimeKind === 'test-double' && !opts.allowTestDouble) {
534
535
  console.error('Error: test-double runtime mutates real queue state (leases tasks, marks them succeeded with empty output).');
535
536
  console.error('Use --runtime test-double --allow-test-double to acknowledge this risk.');
536
- console.error('For production use, use --runtime config (reads from workflows.yaml) or --runtime pi-ai / openclaw-cli.');
537
+ console.error('For production use, use --runtime config (reads from .pd/config.yaml) or --runtime pi-ai / openclaw-cli.');
537
538
  process.exitCode = 1;
538
539
  return;
539
540
  }
@@ -716,7 +717,7 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
716
717
  decision: isConfigError ? 'config_error' : 'runtime_error',
717
718
  reason: message,
718
719
  nextAction: isConfigError
719
- ? 'Fix the workflows.yaml funnel policy, or use --runtime pi-ai / openclaw-cli with explicit flags'
720
+ ? 'Fix the .pd/config.yaml runtime profile, or use --runtime pi-ai / openclaw-cli with explicit flags'
720
721
  : 'Check runner logs and workspace state; re-run with --runtime test-double to isolate',
721
722
  }, null, 2));
722
723
  } else {
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import * as path from 'path';
11
11
  import { probeRuntime } from '@principles/core/runtime-v2';
12
- import { PDRuntimeError } from '@principles/core/runtime-v2';
12
+ import { PDRuntimeError, isRuntimeConfigError } from '@principles/core/runtime-v2';
13
+ import { resolveRuntimeFromPdConfig, resolveRuntimeWithOverrides } from '../services/resolve-runtime-from-pd-config.js';
13
14
 
14
15
  interface RuntimeProbeOptions {
15
16
  runtime: string;
@@ -139,48 +140,55 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
139
140
  let model = opts.model ?? '';
140
141
  let apiKeyEnv = opts.apiKeyEnv ?? '';
141
142
  let baseUrl = opts.baseUrl ?? '';
142
- let {timeoutMs} = opts;
143
+ let { timeoutMs, maxRetries } = opts;
143
144
 
144
- // D-01: always load workspace policy; CLI values take priority as override
145
+ // PRI-393: always load workspace policy from .pd/config.yaml (not .state/workflows.yaml)
145
146
  if (workspaceDir) {
146
- try {
147
- const { resolveRuntimeConfig, isRuntimeConfigError } = await import('@principles/core/runtime-v2');
148
- const configResult = resolveRuntimeConfig(path.join(workspaceDir, '.state'));
149
- if (isRuntimeConfigError(configResult)) {
150
- console.warn(`Warning: could not load workspace runtime config — ${configResult.message}`);
151
- } else {
152
- const config = configResult;
153
- provider = provider || config.provider || '';
154
- model = model || config.model || '';
155
- apiKeyEnv = apiKeyEnv || config.apiKeyEnv || '';
156
- baseUrl = baseUrl || config.baseUrl || '';
157
- timeoutMs = timeoutMs ?? config.timeoutMs;
158
- }
159
- } catch (err) {
160
- console.warn(`Warning: could not load workspace runtime config — policy fallback disabled: ${err instanceof Error ? err.message : String(err)}`);
147
+ const resolved = resolveRuntimeWithOverrides(workspaceDir, {
148
+ provider: opts.provider,
149
+ model: opts.model,
150
+ apiKeyEnv: opts.apiKeyEnv,
151
+ baseUrl: opts.baseUrl,
152
+ maxRetries: opts.maxRetries,
153
+ timeoutMs: opts.timeoutMs,
154
+ });
155
+ for (const w of resolved.legacyWarnings) console.warn(`Warning: ${w}`);
156
+ if (resolved.mergedConfig) {
157
+ provider = provider || resolved.mergedConfig.provider || '';
158
+ model = model || resolved.mergedConfig.model || '';
159
+ apiKeyEnv = apiKeyEnv || resolved.mergedConfig.apiKeyEnv || '';
160
+ baseUrl = baseUrl || resolved.mergedConfig.baseUrl || '';
161
+ timeoutMs = timeoutMs ?? resolved.mergedConfig.timeoutMs;
162
+ maxRetries = maxRetries ?? resolved.mergedConfig.maxRetries;
163
+ } else if (isRuntimeConfigError(resolved.result)) {
164
+ console.warn(`Warning: could not resolve runtime from .pd/config.yaml — ${resolved.result.message}`);
161
165
  }
162
166
  }
163
167
 
164
168
  if (!provider) {
165
- console.error("error: --provider is required for --runtime pi-ai (or set in --workspace workflows.yaml)");
169
+ console.error("error: --provider is required for --runtime pi-ai (or set in .pd/config.yaml)");
166
170
  console.error(" e.g.: pd runtime probe --runtime pi-ai --provider openrouter --model anthropic/claude-sonnet-4 --apiKeyEnv OPENROUTER_API_KEY");
167
171
  process.exit(1);
172
+ return;
168
173
  }
169
174
  if (!model) {
170
- console.error("error: --model is required for --runtime pi-ai (or set in --workspace workflows.yaml)");
175
+ console.error("error: --model is required for --runtime pi-ai (or set in .pd/config.yaml)");
171
176
  console.error(" e.g.: pd runtime probe --runtime pi-ai --provider openrouter --model anthropic/claude-sonnet-4 --apiKeyEnv OPENROUTER_API_KEY");
172
177
  process.exit(1);
178
+ return;
173
179
  }
174
180
  if (!apiKeyEnv) {
175
- console.error("error: --apiKeyEnv is required for --runtime pi-ai (or set in --workspace workflows.yaml)");
181
+ console.error("error: --apiKeyEnv is required for --runtime pi-ai (or set in .pd/config.yaml)");
176
182
  console.error(" e.g.: pd runtime probe --runtime pi-ai --provider openrouter --model anthropic/claude-sonnet-4 --apiKeyEnv OPENROUTER_API_KEY");
177
183
  process.exit(1);
184
+ return;
178
185
  }
179
186
 
180
187
  // D-09: check env var exists before calling probeRuntime
181
188
  if (!process.env[apiKeyEnv]) {
182
189
  console.error(`error: environment variable '${apiKeyEnv}' is not set`);
183
190
  process.exit(1);
191
+ return;
184
192
  }
185
193
 
186
194
  try {
@@ -190,7 +198,7 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
190
198
  model,
191
199
  apiKeyEnv,
192
200
  baseUrl,
193
- maxRetries: opts.maxRetries,
201
+ maxRetries: maxRetries,
194
202
  timeoutMs: timeoutMs ?? 120_000, // D-04: probe timeout 120s (matches Runtime defaults)
195
203
  });
196
204
 
@@ -265,7 +273,66 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
265
273
  }
266
274
 
267
275
  /**
268
- * pd runtime probe — dispatches to openclaw-cli or pi-ai branch.
276
+ * --runtime config probe branch PRI-393
277
+ * Resolves runtime from .pd/config.yaml, then dispatches to pi-ai or openclaw-cli probe.
278
+ */
279
+ async function handleConfigProbe(opts: RuntimeProbeOptions): Promise<void> {
280
+ const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : undefined;
281
+ if (!workspaceDir) {
282
+ console.error('error: --workspace is required for --runtime config');
283
+ process.exit(1);
284
+ return;
285
+ }
286
+
287
+ const resolved = resolveRuntimeFromPdConfig(workspaceDir);
288
+ for (const w of resolved.legacyWarnings) console.warn(`Warning: ${w}`);
289
+
290
+ if (isRuntimeConfigError(resolved.result)) {
291
+ if (opts.json) {
292
+ console.log(JSON.stringify({
293
+ status: 'failed',
294
+ errorCategory: 'config_error',
295
+ message: resolved.result.message,
296
+ reason: resolved.result.reason,
297
+ nextAction: resolved.result.nextAction,
298
+ configSource: resolved.configSource,
299
+ }, null, 2));
300
+ } else {
301
+ console.error(`error: ${resolved.result.message}`);
302
+ console.error(`nextAction: ${resolved.result.nextAction}`);
303
+ }
304
+ process.exit(1);
305
+ return;
306
+ }
307
+
308
+ const config = resolved.result;
309
+ // Dispatch to the appropriate runtime probe
310
+ if (config.runtimeKind === 'pi-ai') {
311
+ return handlePiAiProbe({
312
+ ...opts,
313
+ provider: opts.provider ?? config.provider,
314
+ model: opts.model ?? config.model,
315
+ apiKeyEnv: opts.apiKeyEnv ?? config.apiKeyEnv,
316
+ baseUrl: opts.baseUrl ?? config.baseUrl,
317
+ timeoutMs: opts.timeoutMs ?? config.timeoutMs,
318
+ });
319
+ }
320
+
321
+ if (config.runtimeKind === 'openclaw-cli') {
322
+ return handleOpenClawProbe({
323
+ ...opts,
324
+ openclawLocal: config.openclawMode === 'local' ? true : opts.openclawLocal,
325
+ openclawGateway: config.openclawMode === 'gateway' ? true : opts.openclawGateway,
326
+ });
327
+ }
328
+
329
+ console.error(`error: unsupported runtimeKind '${config.runtimeKind}' from .pd/config.yaml`);
330
+ process.exit(1);
331
+ return;
332
+ }
333
+
334
+ /**
335
+ * pd runtime probe — dispatches to openclaw-cli, pi-ai, or config branch.
269
336
  */
270
337
  export async function handleRuntimeProbe(opts: RuntimeProbeOptions): Promise<void> {
271
338
  if (opts.runtime === 'openclaw-cli') {
@@ -276,6 +343,11 @@ export async function handleRuntimeProbe(opts: RuntimeProbeOptions): Promise<voi
276
343
  return handlePiAiProbe(opts);
277
344
  }
278
345
 
279
- console.error(`error: unsupported --runtime '${opts.runtime}' (supported: openclaw-cli, pi-ai)`);
346
+ if (opts.runtime === 'config') {
347
+ return handleConfigProbe(opts);
348
+ }
349
+
350
+ console.error(`error: unsupported --runtime '${opts.runtime}' (supported: openclaw-cli, pi-ai, config)`);
280
351
  process.exit(1);
352
+ return;
281
353
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Unified runtime config resolver from .pd/config.yaml — PRI-393
3
+ *
4
+ * All MVP mainline execution paths (probe, run-once, diagnose, pain-retry)
5
+ * MUST use this helper instead of the legacy resolveRuntimeConfig(stateDir)
6
+ * which reads .state/workflows.yaml.
7
+ *
8
+ * Canonical source: .pd/config.yaml via loadPdConfig → resolveRuntimeConfigFromPdConfig.
9
+ * .state/workflows.yaml is reported as a legacy warning only.
10
+ *
11
+ * ERR refs:
12
+ * - ERR-002: fail loud with reason + nextAction
13
+ * - EP-07: runtime state source alignment
14
+ */
15
+
16
+ import {
17
+ resolveRuntimeConfigFromPdConfig,
18
+ isRuntimeConfigError,
19
+ } from '@principles/core/runtime-v2';
20
+ import type {
21
+ RuntimeConfigResult,
22
+ RuntimeConfig,
23
+ RuntimeConfigError,
24
+ } from '@principles/core/runtime-v2';
25
+ import { loadPdConfig } from './pd-config-loader.js';
26
+ import type { PdConfigLoadResult } from './pd-config-loader.js';
27
+
28
+ export interface ResolvedRuntimeFromPdConfig {
29
+ /** The resolved runtime config (or error). */
30
+ result: RuntimeConfigResult;
31
+ /** Legacy files detected (.state/workflows.yaml etc.) — informational only. */
32
+ legacyWarnings: string[];
33
+ /** The PD config load result for downstream use (feature flags, etc.). */
34
+ configLoadResult: PdConfigLoadResult;
35
+ /** Canonical config source label, e.g. ".pd/config.yaml". */
36
+ configSource: string;
37
+ }
38
+
39
+ /**
40
+ * Resolve runtime configuration exclusively from .pd/config.yaml.
41
+ *
42
+ * This is the ONLY production entry point for runtime config resolution
43
+ * in pd-cli commands. Legacy resolveRuntimeConfig(stateDir) must NOT be
44
+ * called by probe/run-once/diagnose/pain-retry.
45
+ *
46
+ * @param workspaceDir - The resolved workspace directory.
47
+ * @param getEnvVar - Env var accessor, defaults to process.env.
48
+ * @returns Resolved runtime config with legacy warnings.
49
+ */
50
+ export function resolveRuntimeFromPdConfig(
51
+ workspaceDir: string,
52
+ getEnvVar: (name: string) => string | undefined = (name) => process.env[name],
53
+ ): ResolvedRuntimeFromPdConfig {
54
+ const configLoadResult = loadPdConfig(workspaceDir);
55
+
56
+ // Malformed config → fail loud. Do NOT fall back to defaults for execution.
57
+ // Missing config is ok (loadPdConfig returns ok:true with defaults), but
58
+ // ok:false always means the file exists and is broken.
59
+ if (!configLoadResult.ok) {
60
+ const [firstError] = configLoadResult.errors;
61
+ const result: RuntimeConfigError = {
62
+ ok: false,
63
+ reason: `config_malformed:${firstError?.reason ?? 'unknown'}`,
64
+ message: firstError?.reason ?? '.pd/config.yaml is malformed',
65
+ nextAction: firstError?.nextAction ?? 'Fix .pd/config.yaml syntax and retry',
66
+ };
67
+
68
+ const legacyWarnings = configLoadResult.legacyFilesDetected.length > 0
69
+ ? [
70
+ `Legacy config files detected: ${configLoadResult.legacyFilesDetected.join(', ')}. ` +
71
+ `These are NOT used for runtime resolution. PD uses .pd/config.yaml exclusively.`,
72
+ ]
73
+ : [];
74
+
75
+ return {
76
+ result,
77
+ legacyWarnings,
78
+ configLoadResult,
79
+ configSource: '.pd/config.yaml',
80
+ };
81
+ }
82
+
83
+ const result = resolveRuntimeConfigFromPdConfig(configLoadResult.effective, getEnvVar);
84
+
85
+ const legacyWarnings = configLoadResult.legacyFilesDetected.length > 0
86
+ ? [
87
+ `Legacy config files detected: ${configLoadResult.legacyFilesDetected.join(', ')}. ` +
88
+ `These are NOT used for runtime resolution. PD uses .pd/config.yaml exclusively.`,
89
+ ]
90
+ : [];
91
+
92
+ return {
93
+ result,
94
+ legacyWarnings,
95
+ configLoadResult,
96
+ configSource: '.pd/config.yaml',
97
+ };
98
+ }
99
+
100
+
101
+ /**
102
+ * Resolve runtime config from .pd/config.yaml, then merge with CLI flag overrides.
103
+ *
104
+ * CLI flags take priority over config values (same semantics as before PRI-393,
105
+ * but reading from .pd/config.yaml instead of .state/workflows.yaml).
106
+ *
107
+ * @param workspaceDir - The resolved workspace directory.
108
+ * @param overrides - CLI flag overrides (provider, model, apiKeyEnv, etc.).
109
+ * @param getEnvVar - Env var accessor.
110
+ */
111
+ export function resolveRuntimeWithOverrides(
112
+ workspaceDir: string,
113
+ overrides: {
114
+ provider?: string;
115
+ model?: string;
116
+ apiKeyEnv?: string;
117
+ baseUrl?: string;
118
+ maxRetries?: number;
119
+ timeoutMs?: number;
120
+ },
121
+ getEnvVar: (name: string) => string | undefined = (name) => process.env[name],
122
+ ): ResolvedRuntimeFromPdConfig & { mergedConfig: RuntimeConfig | null } {
123
+ const base = resolveRuntimeFromPdConfig(workspaceDir, getEnvVar);
124
+
125
+ if (isRuntimeConfigError(base.result)) {
126
+ return { ...base, mergedConfig: null };
127
+ }
128
+
129
+ const config = base.result;
130
+ // CLI flags override config values
131
+ const merged: RuntimeConfig = {
132
+ ...config,
133
+ provider: overrides.provider || config.provider,
134
+ model: overrides.model || config.model,
135
+ apiKeyEnv: overrides.apiKeyEnv || config.apiKeyEnv,
136
+ baseUrl: overrides.baseUrl || config.baseUrl,
137
+ maxRetries: overrides.maxRetries ?? config.maxRetries,
138
+ timeoutMs: overrides.timeoutMs ?? config.timeoutMs,
139
+ };
140
+
141
+ return { ...base, mergedConfig: merged };
142
+ }
@@ -55,65 +55,32 @@ describe('Port competition scenarios', () => {
55
55
  });
56
56
 
57
57
  it('findAvailablePort skips occupied ports in sequence', async () => {
58
- // Occupy 3 consecutive ports using dynamically allocated base port
59
- const servers: net.Server[] = [];
60
- // First, get a dynamic port to use as base
61
- const probeServer = net.createServer();
62
- const basePort = await new Promise<number>((resolve) => {
63
- probeServer.listen(0, '127.0.0.1', () => {
64
- const addr = probeServer.address();
65
- if (typeof addr === 'object' && addr) resolve(addr.port);
66
- });
67
- });
68
- await new Promise<void>((resolve) => probeServer.close(() => resolve()));
69
-
70
- for (let i = 0; i < 3; i++) {
71
- const s = net.createServer();
72
- await new Promise<void>((resolve) => {
73
- s.listen(basePort + i, '127.0.0.1', () => resolve());
74
- });
75
- servers.push(s);
76
- }
77
-
58
+ // Use mock to simulate 3 consecutive occupied ports avoids flaky real-network I/O
59
+ const basePort = 49200;
60
+ (globalThis as any).__mockIsPortInUse = async (_host: string, port: number) => {
61
+ return port >= basePort && port <= basePort + 2;
62
+ };
78
63
  try {
79
- // Should skip all 3 and return the next free one
80
64
  const port = await findAvailablePort('127.0.0.1', basePort, 5);
65
+ // Should skip basePort, basePort+1, basePort+2 and return basePort+3
81
66
  expect(port).toBe(basePort + 3);
82
67
  } finally {
83
- for (const s of servers) {
84
- await new Promise<void>((resolve) => s.close(() => resolve()));
85
- }
68
+ delete (globalThis as any).__mockIsPortInUse;
86
69
  }
87
70
  });
88
71
 
89
72
  it('returns null when all fallback ports are exhausted', async () => {
90
- // Occupy a range of ports using dynamically allocated base port
91
- const servers: net.Server[] = [];
92
- const probeServer = net.createServer();
93
- const basePort = await new Promise<number>((resolve) => {
94
- probeServer.listen(0, '127.0.0.1', () => {
95
- const addr = probeServer.address();
96
- if (typeof addr === 'object' && addr) resolve(addr.port);
97
- });
98
- });
99
- await new Promise<void>((resolve) => probeServer.close(() => resolve()));
100
-
101
- for (let i = 0; i < 10; i++) {
102
- const s = net.createServer();
103
- await new Promise<void>((resolve) => {
104
- s.listen(basePort + i, '127.0.0.1', () => resolve());
105
- });
106
- servers.push(s);
107
- }
108
-
73
+ // Use mock to simulate all ports occupied avoids flaky real-network I/O
74
+ const basePort = 49300;
75
+ (globalThis as any).__mockIsPortInUse = async (_host: string, port: number) => {
76
+ return port >= basePort && port <= basePort + 9;
77
+ };
109
78
  try {
110
- // With limit=5, should return null after exhausting fallback
79
+ // With limit=5, all 5 candidates are occupied → null
111
80
  const port = await findAvailablePort('127.0.0.1', basePort, 5);
112
81
  expect(port).toBeNull();
113
82
  } finally {
114
- for (const s of servers) {
115
- await new Promise<void>((resolve) => s.close(() => resolve()));
116
- }
83
+ delete (globalThis as any).__mockIsPortInUse;
117
84
  }
118
85
  });
119
86
  });
@@ -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 resolveRuntimeConfig', async () => {
181
- mockResolveRuntimeConfig.mockReturnValueOnce({
182
- ok: false,
183
- reason: 'missing_openclaw_mode',
184
- message: 'runtimeKind is openclaw-cli but no mode specified',
185
- nextAction: 'Provide exactly one mode',
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 specified'));
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
- mockResolveRuntimeConfig.mockReturnValueOnce({
230
- runtimeKind: 'openclaw-cli',
231
- openclawMode: 'local',
232
- timeoutMs: 300000,
233
- agentId: 'main',
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
- mockResolveRuntimeConfig.mockReturnValueOnce({
255
- runtimeKind: 'openclaw-cli',
256
- openclawMode: 'gateway',
257
- timeoutMs: 300000,
258
- agentId: 'main',
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
- mockResolveRuntimeConfig.mockReturnValueOnce({
281
- ok: false,
282
- reason: 'missing_openclaw_mode',
283
- message: 'runtimeKind is openclaw-cli but no mode specified',
284
- nextAction: 'Provide exactly one mode',
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
- mockResolveRuntimeConfig.mockReturnValueOnce({
327
- runtimeKind: 'openclaw-cli',
328
- openclawMode: 'gateway',
329
- timeoutMs: 300000,
330
- agentId: 'main',
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
- mockResolveRuntimeConfig.mockReturnValueOnce({
359
- runtimeKind: 'openclaw-cli',
360
- openclawMode: 'local',
361
- timeoutMs: 300000,
362
- agentId: 'main',
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);