@principles/pd-cli 1.99.0 → 1.101.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.d.ts.map +1 -1
- package/dist/commands/diagnose.js +4 -3
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/pain-retry.d.ts.map +1 -1
- package/dist/commands/pain-retry.js +4 -3
- package/dist/commands/pain-retry.js.map +1 -1
- package/dist/commands/runtime-internalization-run-once.js +7 -7
- package/dist/commands/runtime-internalization-run-once.js.map +1 -1
- package/dist/commands/runtime-recovery-failed-tasks.d.ts +10 -0
- package/dist/commands/runtime-recovery-failed-tasks.d.ts.map +1 -0
- package/dist/commands/runtime-recovery-failed-tasks.js +164 -0
- package/dist/commands/runtime-recovery-failed-tasks.js.map +1 -0
- package/dist/commands/task.d.ts.map +1 -1
- package/dist/commands/task.js +85 -4
- package/dist/commands/task.js.map +1 -1
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/diagnose.ts +4 -3
- package/src/commands/pain-retry.ts +4 -3
- package/src/commands/runtime-internalization-run-once.ts +7 -7
- package/src/commands/runtime-recovery-failed-tasks.ts +181 -0
- package/src/commands/task.ts +80 -4
- package/src/index.ts +19 -0
- package/tests/commands/runtime-internalization-integrity-repair.test.ts +38 -0
- package/tests/commands/runtime-recovery-failed-tasks.test.ts +201 -0
- package/tests/commands/task.test.ts +153 -0
package/src/commands/task.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* pd task show <taskId>
|
|
7
7
|
*/
|
|
8
8
|
import * as path from 'path';
|
|
9
|
-
import { RuntimeStateManager } from '@principles/core';
|
|
9
|
+
import { RuntimeStateManager, MalformedRunError, type RunRecord } from '@principles/core';
|
|
10
10
|
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
11
11
|
|
|
12
12
|
interface TaskListOptions {
|
|
@@ -77,14 +77,66 @@ export async function handleTaskShow(opts: TaskShowOptions): Promise<void> {
|
|
|
77
77
|
const task = await stateManager.getTask(opts.id);
|
|
78
78
|
|
|
79
79
|
if (!task) {
|
|
80
|
-
|
|
80
|
+
if (opts.json) {
|
|
81
|
+
console.log(JSON.stringify({
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: `Task not found: ${opts.id}`,
|
|
84
|
+
nextAction: 'Specify a valid taskId',
|
|
85
|
+
}, null, 2));
|
|
86
|
+
} else {
|
|
87
|
+
console.error(`Task not found: ${opts.id}`);
|
|
88
|
+
}
|
|
81
89
|
process.exit(1);
|
|
90
|
+
return;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
|
|
93
|
+
let runs: RunRecord[] = [];
|
|
94
|
+
let degradedRuns: { runId: string; error: string; rawRow: Record<string, unknown> }[] = [];
|
|
95
|
+
let isDegraded = false;
|
|
96
|
+
let malformedError: MalformedRunError | null = null;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
runs = await stateManager.getRunsByTask(opts.id);
|
|
100
|
+
} catch (err: unknown) {
|
|
101
|
+
if (err instanceof MalformedRunError) {
|
|
102
|
+
const { validRuns, degradedRuns: malformedRuns } = err;
|
|
103
|
+
runs = validRuns;
|
|
104
|
+
degradedRuns = malformedRuns;
|
|
105
|
+
isDegraded = true;
|
|
106
|
+
malformedError = err;
|
|
107
|
+
} else {
|
|
108
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
109
|
+
if (opts.json) {
|
|
110
|
+
console.log(JSON.stringify({
|
|
111
|
+
ok: false,
|
|
112
|
+
reason: errorMsg,
|
|
113
|
+
nextAction: 'Check state.db connection and schema integrity',
|
|
114
|
+
}, null, 2));
|
|
115
|
+
} else {
|
|
116
|
+
console.error(`Error: ${errorMsg}`);
|
|
117
|
+
}
|
|
118
|
+
process.exit(1);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
85
122
|
|
|
86
123
|
if (opts.json) {
|
|
87
|
-
|
|
124
|
+
if (isDegraded) {
|
|
125
|
+
console.log(JSON.stringify({
|
|
126
|
+
ok: false,
|
|
127
|
+
task,
|
|
128
|
+
runs,
|
|
129
|
+
degradedRuns: degradedRuns.map(dr => ({
|
|
130
|
+
runId: dr.runId,
|
|
131
|
+
error: dr.error,
|
|
132
|
+
})),
|
|
133
|
+
reason: malformedError?.message ?? 'Unknown malformed run schema',
|
|
134
|
+
nextAction: 'Malformed run rows are visible here but not auto-repaired. Runner execution tolerates them (uses the latest valid run). To quarantine malformed rows: pd runtime internalization integrity-repair --confirm --json',
|
|
135
|
+
}, null, 2));
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
} else {
|
|
138
|
+
console.log(JSON.stringify({ task, runs }, null, 2));
|
|
139
|
+
}
|
|
88
140
|
return;
|
|
89
141
|
}
|
|
90
142
|
|
|
@@ -124,6 +176,30 @@ export async function handleTaskShow(opts: TaskShowOptions): Promise<void> {
|
|
|
124
176
|
}
|
|
125
177
|
console.log('');
|
|
126
178
|
}
|
|
179
|
+
|
|
180
|
+
if (isDegraded && degradedRuns.length > 0) {
|
|
181
|
+
console.warn(`WARNING: Task has ${degradedRuns.length} malformed run(s) in database!`);
|
|
182
|
+
console.warn(`Reason: ${malformedError?.message ?? 'Unknown malformed run schema'}`);
|
|
183
|
+
console.warn(`nextAction: Malformed run rows are visible here but not auto-repaired. Runner execution tolerates them (uses the latest valid run). To quarantine malformed rows: pd runtime internalization integrity-repair --confirm --json\n`);
|
|
184
|
+
console.log('Degraded Runs:');
|
|
185
|
+
for (const dr of degradedRuns) {
|
|
186
|
+
console.log(` - Run: ${dr.runId}`);
|
|
187
|
+
console.log(` Error: ${dr.error}`);
|
|
188
|
+
}
|
|
189
|
+
console.log('');
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
}
|
|
192
|
+
} catch (err: unknown) {
|
|
193
|
+
if (opts.json) {
|
|
194
|
+
console.log(JSON.stringify({
|
|
195
|
+
ok: false,
|
|
196
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
197
|
+
nextAction: 'Check workspace path and task ID',
|
|
198
|
+
}, null, 2));
|
|
199
|
+
} else {
|
|
200
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
201
|
+
}
|
|
202
|
+
process.exitCode = 1;
|
|
127
203
|
} finally {
|
|
128
204
|
await stateManager.close();
|
|
129
205
|
}
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { handleRuntimeInternalizationIntegrityRepair } from './commands/runtime-
|
|
|
44
44
|
import { handleRuntimeInternalizationEnqueueSuccessors } from './commands/runtime-internalization-enqueue-successors.js';
|
|
45
45
|
import { handleRuntimeDiagnosticsExport } from './commands/runtime-diagnostics-export.js';
|
|
46
46
|
import { handleRuntimeRecoverySweep } from './commands/runtime-recovery.js';
|
|
47
|
+
import { handleRuntimeRecoveryFailedTasks } from './commands/runtime-recovery-failed-tasks.js';
|
|
47
48
|
import { handleRuntimeActivationDispatch } from './commands/runtime-activation.js';
|
|
48
49
|
import { handleProvenChannelBaseline } from './commands/proven-channel-baseline.js';
|
|
49
50
|
import { handleDemoStoryA } from './commands/demo-story-a.js';
|
|
@@ -645,6 +646,24 @@ recoveryCmd
|
|
|
645
646
|
await handleRuntimeRecoverySweep({ workspace: opts.workspace, dryRun: opts.dryRun, confirm: opts.confirm, json: opts.json });
|
|
646
647
|
});
|
|
647
648
|
|
|
649
|
+
recoveryCmd
|
|
650
|
+
.command('failed-tasks')
|
|
651
|
+
.description('Recover failed internalization tasks')
|
|
652
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
653
|
+
.option('--dry-run', 'Report only, no modifications (default)')
|
|
654
|
+
.option('--confirm', 'Actually recover failed tasks')
|
|
655
|
+
.option('--force', 'Force recovery of tasks that exhausted max attempts')
|
|
656
|
+
.option('--json', 'Output raw JSON')
|
|
657
|
+
.action(async (opts) => {
|
|
658
|
+
await handleRuntimeRecoveryFailedTasks({
|
|
659
|
+
workspace: opts.workspace,
|
|
660
|
+
dryRun: opts.dryRun,
|
|
661
|
+
confirm: opts.confirm,
|
|
662
|
+
force: opts.force,
|
|
663
|
+
json: opts.json,
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
648
667
|
diagnosticsCmd
|
|
649
668
|
.command('export')
|
|
650
669
|
.description('Export diagnostic bundle for AI assistant analysis')
|
|
@@ -82,6 +82,44 @@ describe('handleRuntimeInternalizationIntegrityRepair', () => {
|
|
|
82
82
|
expect(jsonOutput.repairedCount).toBe(1);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
it('emits malformed_run_row quarantine actions as a single JSON object', async () => {
|
|
86
|
+
// Verifies the new malformed-run repair pass flows through the CLI contract
|
|
87
|
+
// unchanged: exactly one JSON object on stdout, with the quarantine action present.
|
|
88
|
+
mockRepair.mockReturnValue(makeResult({
|
|
89
|
+
dryRun: false,
|
|
90
|
+
repairedCount: 1,
|
|
91
|
+
actions: [
|
|
92
|
+
{
|
|
93
|
+
action: 'quarantine_malformed_run',
|
|
94
|
+
targetId: 'run-malf-1',
|
|
95
|
+
taskId: 'task-1',
|
|
96
|
+
type: 'malformed_run_row',
|
|
97
|
+
severity: 'warning',
|
|
98
|
+
previousState: 'queued',
|
|
99
|
+
nextState: 'failed',
|
|
100
|
+
previousStatus: 'queued',
|
|
101
|
+
newStatus: 'failed',
|
|
102
|
+
recommendedAction: 'quarantine_malformed_run',
|
|
103
|
+
reason: 'Run run-malf-1 (task task-1) failed schema validation — quarantined',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
await handleRuntimeInternalizationIntegrityRepair({ workspace: WS, confirm: true, json: true });
|
|
109
|
+
|
|
110
|
+
// Exactly one console.log call (single JSON object, no banners/extra stdout).
|
|
111
|
+
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
|
|
112
|
+
const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
|
|
113
|
+
expect(jsonOutput.repairedCount).toBe(1);
|
|
114
|
+
expect(jsonOutput.actions).toHaveLength(1);
|
|
115
|
+
expect(jsonOutput.actions[0]).toMatchObject({
|
|
116
|
+
type: 'malformed_run_row',
|
|
117
|
+
recommendedAction: 'quarantine_malformed_run',
|
|
118
|
+
newStatus: 'failed',
|
|
119
|
+
targetId: 'run-malf-1',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
85
123
|
it('outputs text format when --json not specified', async () => {
|
|
86
124
|
mockRepair.mockReturnValue(makeResult({ dryRun: true }));
|
|
87
125
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mockDetectFailedTasks = vi.hoisted(() => vi.fn());
|
|
4
|
+
const mockRecoverFailedTask = vi.hoisted(() => vi.fn());
|
|
5
|
+
const mockServiceClose = vi.hoisted(() => vi.fn());
|
|
6
|
+
|
|
7
|
+
vi.mock('../../src/resolve-workspace.js', () => ({
|
|
8
|
+
resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('@principles/core/runtime-v2', () => ({
|
|
12
|
+
createRecoverySweepService: vi.fn().mockResolvedValue({
|
|
13
|
+
service: {
|
|
14
|
+
detectFailedTasks: mockDetectFailedTasks,
|
|
15
|
+
recoverFailedTask: mockRecoverFailedTask,
|
|
16
|
+
},
|
|
17
|
+
close: mockServiceClose,
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { handleRuntimeRecoveryFailedTasks } from '../../src/commands/runtime-recovery-failed-tasks.js';
|
|
22
|
+
|
|
23
|
+
describe('pd runtime recovery failed-tasks command contract', () => {
|
|
24
|
+
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
25
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
mockDetectFailedTasks.mockResolvedValue([]);
|
|
30
|
+
mockRecoverFailedTask.mockResolvedValue({
|
|
31
|
+
taskId: 'task-1',
|
|
32
|
+
previousStatus: 'failed',
|
|
33
|
+
newStatus: 'pending',
|
|
34
|
+
attemptCount: 0,
|
|
35
|
+
maxAttempts: 3,
|
|
36
|
+
forceApplied: false,
|
|
37
|
+
});
|
|
38
|
+
mockServiceClose.mockResolvedValue(undefined);
|
|
39
|
+
process.exitCode = 0;
|
|
40
|
+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
41
|
+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('dry-run JSON does not recover tasks and lists them as would_recover', async () => {
|
|
45
|
+
mockDetectFailedTasks.mockResolvedValue([
|
|
46
|
+
{
|
|
47
|
+
taskId: 'task-1',
|
|
48
|
+
taskKind: 'dreamer',
|
|
49
|
+
attemptCount: 1,
|
|
50
|
+
maxAttempts: 3,
|
|
51
|
+
isExhausted: false,
|
|
52
|
+
status: 'failed',
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', dryRun: true, json: true });
|
|
57
|
+
|
|
58
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
|
|
59
|
+
expect(output).toMatchObject({
|
|
60
|
+
ok: true,
|
|
61
|
+
mode: 'dry_run',
|
|
62
|
+
recoveredCount: 1,
|
|
63
|
+
skippedCount: 0,
|
|
64
|
+
});
|
|
65
|
+
expect(output.tasks[0]).toMatchObject({
|
|
66
|
+
taskId: 'task-1',
|
|
67
|
+
action: 'would_recover',
|
|
68
|
+
reason: expect.stringContaining('attempts remain'),
|
|
69
|
+
});
|
|
70
|
+
expect(mockRecoverFailedTask).not.toHaveBeenCalled();
|
|
71
|
+
expect(mockServiceClose).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(process.exitCode).toBe(1); // exitCode 1 on dry-run when tasks are found
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('confirm JSON reports recovered tasks and mutates state', async () => {
|
|
76
|
+
mockDetectFailedTasks.mockResolvedValue([
|
|
77
|
+
{
|
|
78
|
+
taskId: 'task-1',
|
|
79
|
+
taskKind: 'dreamer',
|
|
80
|
+
attemptCount: 1,
|
|
81
|
+
maxAttempts: 3,
|
|
82
|
+
isExhausted: false,
|
|
83
|
+
status: 'failed',
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', confirm: true, json: true });
|
|
88
|
+
|
|
89
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
|
|
90
|
+
expect(output).toMatchObject({
|
|
91
|
+
ok: true,
|
|
92
|
+
mode: 'confirm',
|
|
93
|
+
recoveredCount: 1,
|
|
94
|
+
skippedCount: 0,
|
|
95
|
+
});
|
|
96
|
+
expect(output.tasks[0]).toMatchObject({
|
|
97
|
+
taskId: 'task-1',
|
|
98
|
+
action: 'recovered',
|
|
99
|
+
});
|
|
100
|
+
expect(mockRecoverFailedTask).toHaveBeenCalledWith('task-1', undefined);
|
|
101
|
+
expect(mockServiceClose).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(process.exitCode).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('dry-run JSON skips exhausted tasks without force', async () => {
|
|
106
|
+
mockDetectFailedTasks.mockResolvedValue([
|
|
107
|
+
{
|
|
108
|
+
taskId: 'task-exhausted',
|
|
109
|
+
taskKind: 'dreamer',
|
|
110
|
+
attemptCount: 3,
|
|
111
|
+
maxAttempts: 3,
|
|
112
|
+
isExhausted: true,
|
|
113
|
+
status: 'failed',
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', dryRun: true, json: true });
|
|
118
|
+
|
|
119
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
|
|
120
|
+
expect(output).toMatchObject({
|
|
121
|
+
ok: true,
|
|
122
|
+
mode: 'dry_run',
|
|
123
|
+
recoveredCount: 0,
|
|
124
|
+
skippedCount: 1,
|
|
125
|
+
});
|
|
126
|
+
expect(output.tasks[0]).toMatchObject({
|
|
127
|
+
taskId: 'task-exhausted',
|
|
128
|
+
action: 'skipped',
|
|
129
|
+
reason: expect.stringContaining('exhausted max attempts'),
|
|
130
|
+
});
|
|
131
|
+
expect(mockRecoverFailedTask).not.toHaveBeenCalled();
|
|
132
|
+
expect(process.exitCode).toBe(0); // exitCode 0 since recoveredCount is 0
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('confirm with force recovers exhausted tasks', async () => {
|
|
136
|
+
mockDetectFailedTasks.mockResolvedValue([
|
|
137
|
+
{
|
|
138
|
+
taskId: 'task-exhausted',
|
|
139
|
+
taskKind: 'dreamer',
|
|
140
|
+
attemptCount: 3,
|
|
141
|
+
maxAttempts: 3,
|
|
142
|
+
isExhausted: true,
|
|
143
|
+
status: 'failed',
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', confirm: true, force: true, json: true });
|
|
148
|
+
|
|
149
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
|
|
150
|
+
expect(output).toMatchObject({
|
|
151
|
+
ok: true,
|
|
152
|
+
mode: 'confirm',
|
|
153
|
+
recoveredCount: 1,
|
|
154
|
+
skippedCount: 0,
|
|
155
|
+
});
|
|
156
|
+
expect(mockRecoverFailedTask).toHaveBeenCalledWith('task-exhausted', true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rejects mutual exclusion of dry-run and confirm in JSON mode', async () => {
|
|
160
|
+
await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', dryRun: true, confirm: true, json: true });
|
|
161
|
+
|
|
162
|
+
expect(mockRecoverFailedTask).not.toHaveBeenCalled();
|
|
163
|
+
expect(mockServiceClose).not.toHaveBeenCalled();
|
|
164
|
+
expect(process.exitCode).toBe(1);
|
|
165
|
+
|
|
166
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
|
|
167
|
+
expect(output).toMatchObject({
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: expect.stringContaining('mutually exclusive'),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('confirm JSON handles concurrent task modification gracefully (null result)', async () => {
|
|
174
|
+
mockDetectFailedTasks.mockResolvedValue([
|
|
175
|
+
{
|
|
176
|
+
taskId: 'task-concurrent',
|
|
177
|
+
taskKind: 'dreamer',
|
|
178
|
+
attemptCount: 1,
|
|
179
|
+
maxAttempts: 3,
|
|
180
|
+
isExhausted: false,
|
|
181
|
+
status: 'failed',
|
|
182
|
+
},
|
|
183
|
+
]);
|
|
184
|
+
mockRecoverFailedTask.mockResolvedValue(null);
|
|
185
|
+
|
|
186
|
+
await handleRuntimeRecoveryFailedTasks({ workspace: '/fake/workspace', confirm: true, json: true });
|
|
187
|
+
|
|
188
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
|
|
189
|
+
expect(output).toMatchObject({
|
|
190
|
+
ok: true,
|
|
191
|
+
mode: 'confirm',
|
|
192
|
+
recoveredCount: 0,
|
|
193
|
+
skippedCount: 1,
|
|
194
|
+
});
|
|
195
|
+
expect(output.tasks[0]).toMatchObject({
|
|
196
|
+
taskId: 'task-concurrent',
|
|
197
|
+
action: 'skipped',
|
|
198
|
+
reason: expect.stringContaining('no longer failed or concurrently modified'),
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mockGetTask = vi.hoisted(() => vi.fn());
|
|
4
|
+
const mockGetRunsByTask = vi.hoisted(() => vi.fn());
|
|
5
|
+
const mockInitialize = vi.hoisted(() => vi.fn());
|
|
6
|
+
const mockClose = vi.hoisted(() => vi.fn());
|
|
7
|
+
|
|
8
|
+
vi.mock('../../src/resolve-workspace.js', () => ({
|
|
9
|
+
resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('@principles/core', async (importOriginal) => {
|
|
13
|
+
const original = await importOriginal<typeof import('@principles/core')>();
|
|
14
|
+
return {
|
|
15
|
+
...original,
|
|
16
|
+
RuntimeStateManager: vi.fn().mockImplementation(function() {
|
|
17
|
+
return {
|
|
18
|
+
initialize: mockInitialize,
|
|
19
|
+
getTask: mockGetTask,
|
|
20
|
+
getRunsByTask: mockGetRunsByTask,
|
|
21
|
+
close: mockClose,
|
|
22
|
+
};
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
import { handleTaskShow } from '../../src/commands/task.js';
|
|
28
|
+
import { MalformedRunError } from '@principles/core';
|
|
29
|
+
|
|
30
|
+
describe('pd task show command handler', () => {
|
|
31
|
+
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
32
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
33
|
+
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
|
34
|
+
let processExitSpy: ReturnType<typeof vi.spyOn>;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
mockInitialize.mockResolvedValue(undefined);
|
|
39
|
+
mockGetTask.mockResolvedValue({
|
|
40
|
+
taskId: 'task-123',
|
|
41
|
+
taskKind: 'dreamer',
|
|
42
|
+
status: 'failed',
|
|
43
|
+
attemptCount: 1,
|
|
44
|
+
maxAttempts: 3,
|
|
45
|
+
createdAt: Date.now(),
|
|
46
|
+
updatedAt: Date.now(),
|
|
47
|
+
});
|
|
48
|
+
mockGetRunsByTask.mockResolvedValue([
|
|
49
|
+
{
|
|
50
|
+
runId: 'run-1',
|
|
51
|
+
executionStatus: 'failed',
|
|
52
|
+
attemptNumber: 1,
|
|
53
|
+
startedAt: Date.now(),
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
process.exitCode = 0;
|
|
57
|
+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
58
|
+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
59
|
+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
60
|
+
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('happy path JSON mode prints task and runs, exit code 0', async () => {
|
|
64
|
+
await handleTaskShow({ id: 'task-123', json: true });
|
|
65
|
+
|
|
66
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
|
|
67
|
+
expect(output).toMatchObject({
|
|
68
|
+
task: { taskId: 'task-123' },
|
|
69
|
+
runs: [{ runId: 'run-1' }],
|
|
70
|
+
});
|
|
71
|
+
expect(process.exitCode).toBe(0);
|
|
72
|
+
expect(processExitSpy).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('task not found JSON mode prints ok: false and exits with non-zero code', async () => {
|
|
76
|
+
mockGetTask.mockResolvedValue(null);
|
|
77
|
+
|
|
78
|
+
await handleTaskShow({ id: 'nonexistent-task', json: true });
|
|
79
|
+
|
|
80
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
|
|
81
|
+
expect(output).toMatchObject({
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: expect.stringContaining('Task not found'),
|
|
84
|
+
nextAction: expect.any(String),
|
|
85
|
+
});
|
|
86
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('degraded JSON mode prints ok: false, lists degraded runs, and sets exitCode to 1', async () => {
|
|
90
|
+
const malformedError = new MalformedRunError('Malformed schema', [
|
|
91
|
+
{
|
|
92
|
+
runId: 'run-valid',
|
|
93
|
+
executionStatus: 'succeeded',
|
|
94
|
+
attemptNumber: 1,
|
|
95
|
+
startedAt: Date.now(),
|
|
96
|
+
} as any,
|
|
97
|
+
], [
|
|
98
|
+
{
|
|
99
|
+
runId: 'run-bad',
|
|
100
|
+
error: 'runtimeKind missing',
|
|
101
|
+
rawRow: {},
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
mockGetRunsByTask.mockRejectedValue(malformedError);
|
|
106
|
+
|
|
107
|
+
await handleTaskShow({ id: 'task-123', json: true });
|
|
108
|
+
|
|
109
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string);
|
|
110
|
+
expect(output).toMatchObject({
|
|
111
|
+
ok: false,
|
|
112
|
+
task: { taskId: 'task-123' },
|
|
113
|
+
runs: [{ runId: 'run-valid' }],
|
|
114
|
+
degradedRuns: [{ runId: 'run-bad', error: 'runtimeKind missing' }],
|
|
115
|
+
reason: expect.stringContaining('Malformed schema'),
|
|
116
|
+
// Honest nextAction: must NOT promise an auto-repair that doesn't exist,
|
|
117
|
+
// and must point at the real quarantine command (integrity-repair --confirm).
|
|
118
|
+
nextAction: expect.stringContaining('not auto-repaired'),
|
|
119
|
+
});
|
|
120
|
+
expect(output.nextAction).toContain('integrity-repair --confirm');
|
|
121
|
+
expect(process.exitCode).toBe(1);
|
|
122
|
+
expect(processExitSpy).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('degraded text mode prints warning and sets exitCode to 1', async () => {
|
|
126
|
+
const malformedError = new MalformedRunError('Malformed schema', [
|
|
127
|
+
{
|
|
128
|
+
runId: 'run-valid',
|
|
129
|
+
executionStatus: 'succeeded',
|
|
130
|
+
attemptNumber: 1,
|
|
131
|
+
startedAt: Date.now(),
|
|
132
|
+
} as any,
|
|
133
|
+
], [
|
|
134
|
+
{
|
|
135
|
+
runId: 'run-bad',
|
|
136
|
+
error: 'runtimeKind missing',
|
|
137
|
+
rawRow: {},
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
mockGetRunsByTask.mockRejectedValue(malformedError);
|
|
142
|
+
|
|
143
|
+
await handleTaskShow({ id: 'task-123', json: false });
|
|
144
|
+
|
|
145
|
+
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
146
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Task: task-123'));
|
|
147
|
+
// The text-mode warning must also carry the honest nextAction.
|
|
148
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('not auto-repaired'));
|
|
149
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('integrity-repair --confirm'));
|
|
150
|
+
expect(process.exitCode).toBe(1);
|
|
151
|
+
expect(processExitSpy).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
});
|