@pixelbyte-software/pixcode 1.42.1 → 1.42.3
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/assets/{index-C97kIvXz.js → index-BnaWRV1a.js} +182 -182
- package/dist/index.html +1 -1
- package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js +86 -0
- package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js.map +1 -1
- package/dist-server/server/modules/orchestration/tasks/task-run-graph.js +158 -0
- package/dist-server/server/modules/orchestration/tasks/task-run-graph.js.map +1 -0
- package/dist-server/server/modules/orchestration/workflows/workflow-fallback-policy.js +114 -0
- package/dist-server/server/modules/orchestration/workflows/workflow-fallback-policy.js.map +1 -0
- package/dist-server/server/modules/orchestration/workflows/workflow-replay.js +177 -0
- package/dist-server/server/modules/orchestration/workflows/workflow-replay.js.map +1 -0
- package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +53 -7
- package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -1
- package/dist-server/server/modules/orchestration/workflows/workflow-trace.js +74 -0
- package/dist-server/server/modules/orchestration/workflows/workflow-trace.js.map +1 -1
- package/dist-server/server/modules/orchestration/workflows/workflow.routes.js +88 -0
- package/dist-server/server/modules/orchestration/workflows/workflow.routes.js.map +1 -1
- package/dist-server/server/routes/taskmaster.js +93 -25
- package/dist-server/server/routes/taskmaster.js.map +1 -1
- package/package.json +1 -1
- package/scripts/smoke/taskmaster-run-graph.mjs +55 -0
- package/scripts/smoke/workflow-fallback-replay.mjs +56 -0
- package/server/modules/orchestration/tasks/orchestration-task.service.ts +94 -0
- package/server/modules/orchestration/tasks/orchestration-task.types.ts +10 -0
- package/server/modules/orchestration/tasks/task-run-graph.ts +219 -0
- package/server/modules/orchestration/workflows/workflow-fallback-policy.ts +161 -0
- package/server/modules/orchestration/workflows/workflow-replay.ts +254 -0
- package/server/modules/orchestration/workflows/workflow-runner.ts +112 -7
- package/server/modules/orchestration/workflows/workflow-trace.ts +76 -0
- package/server/modules/orchestration/workflows/workflow.routes.ts +107 -0
- package/server/modules/orchestration/workflows/workflow.types.ts +5 -0
- package/server/routes/taskmaster.js +90 -23
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const root = process.cwd();
|
|
8
|
+
|
|
9
|
+
function read(relativePath) {
|
|
10
|
+
return fs.readFileSync(path.join(root, relativePath), 'utf8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const fallbackPolicy = read('server/modules/orchestration/workflows/workflow-fallback-policy.ts');
|
|
14
|
+
assert.match(fallbackPolicy, /PIXCODE_FALLBACK_POLICY_PROTOCOL/, 'Fallback policy should declare a stable protocol id.');
|
|
15
|
+
assert.match(fallbackPolicy, /pixcode\.fallback-policy\.v1/, 'Fallback policy should use the v1 protocol id.');
|
|
16
|
+
assert.match(fallbackPolicy, /provider_failure/, 'Fallback policy should classify provider failures.');
|
|
17
|
+
assert.match(fallbackPolicy, /timeout/, 'Fallback policy should classify timeouts.');
|
|
18
|
+
assert.match(fallbackPolicy, /tool_failure/, 'Fallback policy should classify tool failures.');
|
|
19
|
+
assert.match(fallbackPolicy, /invalid_output/, 'Fallback policy should classify invalid output.');
|
|
20
|
+
assert.match(fallbackPolicy, /resolveWorkflowFallbackDecision/, 'Fallback policy should expose a decision helper.');
|
|
21
|
+
|
|
22
|
+
const replay = read('server/modules/orchestration/workflows/workflow-replay.ts');
|
|
23
|
+
assert.match(replay, /PIXCODE_REPLAY_PROTOCOL/, 'Replay support should declare a stable protocol id.');
|
|
24
|
+
assert.match(replay, /pixcode\.workflow-replay\.v1/, 'Replay support should use the v1 protocol id.');
|
|
25
|
+
assert.match(replay, /buildWorkflowReplayPlan/, 'Replay support should build a replay plan from stored run data.');
|
|
26
|
+
assert.match(replay, /requiresApproval/, 'Replay plans should expose approval requirements.');
|
|
27
|
+
assert.match(replay, /file-write/, 'Replay safety should detect file-write actions.');
|
|
28
|
+
assert.match(replay, /shell/, 'Replay safety should detect shell actions.');
|
|
29
|
+
assert.match(replay, /network/, 'Replay safety should detect network actions.');
|
|
30
|
+
|
|
31
|
+
const runner = read('server/modules/orchestration/workflows/workflow-runner.ts');
|
|
32
|
+
assert.match(runner, /resolveWorkflowFallbackDecision/, 'Workflow runner should use policy-driven fallback decisions.');
|
|
33
|
+
assert.match(runner, /fallbackTrigger/, 'Fallback nodes should record the trigger that launched them.');
|
|
34
|
+
assert.match(runner, /fallbackSkippedEvents/, 'Skipped fallback decisions should be recorded.');
|
|
35
|
+
|
|
36
|
+
const trace = read('server/modules/orchestration/workflows/workflow-trace.ts');
|
|
37
|
+
assert.match(trace, /workflow\.trace\.fallback/, 'Trace timeline should surface fallback events.');
|
|
38
|
+
assert.match(trace, /workflow\.trace\.replay/, 'Trace timeline should surface replay metadata.');
|
|
39
|
+
|
|
40
|
+
const routes = read('server/modules/orchestration/workflows/workflow.routes.ts');
|
|
41
|
+
assert.match(routes, /replay-plan/, 'Workflow routes should expose a replay plan endpoint.');
|
|
42
|
+
assert.match(routes, /REPLAY_APPROVAL_REQUIRED/, 'Replay route should require approval before unsafe replay.');
|
|
43
|
+
assert.match(routes, /workflowRunner\.start\(\s*replayPlan\.workflow/s, 'Replay route should start from the generated replay workflow.');
|
|
44
|
+
|
|
45
|
+
const panel = read('src/components/orchestration/workflows/WorkflowRunPanel.tsx');
|
|
46
|
+
assert.match(panel, /loadReplayPlan/, 'Workflow run panel should load replay plans.');
|
|
47
|
+
assert.match(panel, /replayRun/, 'Workflow run panel should expose a replay action.');
|
|
48
|
+
assert.match(panel, /approveReplay/, 'Workflow run panel should expose explicit approval for guarded replay.');
|
|
49
|
+
assert.match(panel, /orchestration\.replayRun/, 'Workflow UI should render replay labels.');
|
|
50
|
+
|
|
51
|
+
const en = read('src/i18n/locales/en/common.json');
|
|
52
|
+
const tr = read('src/i18n/locales/tr/common.json');
|
|
53
|
+
assert.match(en, /"replayRun"/, 'English replay translation is missing.');
|
|
54
|
+
assert.match(tr, /"replayRun"/, 'Turkish replay translation is missing.');
|
|
55
|
+
|
|
56
|
+
console.log('workflow fallback replay smoke passed');
|
|
@@ -2,6 +2,7 @@ import { OrchestrationTaskStore } from '@/modules/orchestration/tasks/orchestrat
|
|
|
2
2
|
import type { CreateOrchestrationTaskInput, DispatchOrchestrationTaskInput, OrchestrationTask } from '@/modules/orchestration/tasks/orchestration-task.types.js';
|
|
3
3
|
import { a2aBus } from '@/modules/orchestration/a2a/bus.js';
|
|
4
4
|
import type { TaskState } from '@/modules/orchestration/a2a/types.js';
|
|
5
|
+
import type { WorkflowNodeRun, WorkflowRun } from '@/modules/orchestration/workflows/workflow.types.js';
|
|
5
6
|
|
|
6
7
|
function newId(prefix: string): string {
|
|
7
8
|
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -9,6 +10,52 @@ function newId(prefix: string): string {
|
|
|
9
10
|
|
|
10
11
|
const TERMINAL_A2A_STATES: TaskState[] = ['completed', 'canceled', 'failed'];
|
|
11
12
|
|
|
13
|
+
function uniqueStrings(values: Array<string | undefined>): string[] {
|
|
14
|
+
return [...new Set(values.filter((value): value is string => Boolean(value?.trim())))]
|
|
15
|
+
.sort((a, b) => a.localeCompare(b));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
|
19
|
+
return value && typeof value === 'object' ? value as Record<string, unknown> : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readString(value: unknown): string | undefined {
|
|
23
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function changedFilesFromNode(node: WorkflowNodeRun): string[] {
|
|
27
|
+
const files: string[] = [];
|
|
28
|
+
if (node.handoffArtifact?.changedFiles) {
|
|
29
|
+
files.push(...node.handoffArtifact.changedFiles);
|
|
30
|
+
}
|
|
31
|
+
for (const artifact of node.artifacts ?? []) {
|
|
32
|
+
const data = readRecord(artifact.data);
|
|
33
|
+
const metadata = readRecord(artifact.metadata);
|
|
34
|
+
files.push(
|
|
35
|
+
...[readString(data?.path), readString(data?.file), readString(metadata?.path), readString(metadata?.file)]
|
|
36
|
+
.filter((value): value is string => Boolean(value)),
|
|
37
|
+
);
|
|
38
|
+
for (const key of ['files', 'changedFiles']) {
|
|
39
|
+
const value = data?.[key] ?? metadata?.[key];
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
files.push(...value.map((entry) => readString(entry)).filter((entry): entry is string => Boolean(entry)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return uniqueStrings(files);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function changedFilesFromWorkflowRun(run: WorkflowRun): string[] {
|
|
49
|
+
return uniqueStrings(run.nodeRuns.flatMap((node) => changedFilesFromNode(node)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function workflowRunState(run: WorkflowRun): OrchestrationTask['state'] {
|
|
53
|
+
if (run.status === 'completed') return 'done';
|
|
54
|
+
if (run.status === 'failed') return 'failed';
|
|
55
|
+
if (run.status === 'canceled') return 'canceled';
|
|
56
|
+
return 'in_progress';
|
|
57
|
+
}
|
|
58
|
+
|
|
12
59
|
class OrchestrationTaskService {
|
|
13
60
|
private store: OrchestrationTaskStore;
|
|
14
61
|
|
|
@@ -33,6 +80,8 @@ class OrchestrationTaskService {
|
|
|
33
80
|
title: input.title,
|
|
34
81
|
description: input.description,
|
|
35
82
|
taskmasterId: input.taskmasterId,
|
|
83
|
+
acceptanceCriteria: input.acceptanceCriteria,
|
|
84
|
+
changedFiles: input.changedFiles,
|
|
36
85
|
state: 'todo',
|
|
37
86
|
createdAt: now,
|
|
38
87
|
updatedAt: now,
|
|
@@ -48,6 +97,8 @@ class OrchestrationTaskService {
|
|
|
48
97
|
if (existing) {
|
|
49
98
|
existing.title = input.title;
|
|
50
99
|
existing.description = input.description;
|
|
100
|
+
existing.acceptanceCriteria = input.acceptanceCriteria ?? existing.acceptanceCriteria;
|
|
101
|
+
existing.changedFiles = uniqueStrings([...(existing.changedFiles ?? []), ...(input.changedFiles ?? [])]);
|
|
51
102
|
existing.updatedAt = Date.now();
|
|
52
103
|
this.store.set(existing);
|
|
53
104
|
return existing;
|
|
@@ -99,6 +150,49 @@ class OrchestrationTaskService {
|
|
|
99
150
|
return task;
|
|
100
151
|
}
|
|
101
152
|
|
|
153
|
+
linkWorkflowRun(taskId: string, run: WorkflowRun): OrchestrationTask | undefined {
|
|
154
|
+
const task = this.store.get(taskId);
|
|
155
|
+
if (!task) return undefined;
|
|
156
|
+
task.workflowRunIds = uniqueStrings([...(task.workflowRunIds ?? []), run.id]);
|
|
157
|
+
task.state = workflowRunState(run);
|
|
158
|
+
task.updatedAt = Date.now();
|
|
159
|
+
this.store.set(task);
|
|
160
|
+
return task;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
updateFromWorkflowRun(run: WorkflowRun): OrchestrationTask | undefined {
|
|
164
|
+
const metadata = run.metadata ?? {};
|
|
165
|
+
const taskId = readString(metadata.orchestrationTaskId);
|
|
166
|
+
const taskmasterId = readString(metadata.taskmasterId);
|
|
167
|
+
const task = taskId
|
|
168
|
+
? this.store.get(taskId)
|
|
169
|
+
: taskmasterId
|
|
170
|
+
? this.store.list(readString(metadata.projectId)).find((candidate) => candidate.taskmasterId === taskmasterId)
|
|
171
|
+
: undefined;
|
|
172
|
+
if (!task) return undefined;
|
|
173
|
+
|
|
174
|
+
const changedFiles = changedFilesFromWorkflowRun(run);
|
|
175
|
+
task.workflowRunIds = uniqueStrings([...(task.workflowRunIds ?? []), run.id]);
|
|
176
|
+
task.changedFiles = uniqueStrings([...(task.changedFiles ?? []), ...changedFiles]);
|
|
177
|
+
task.state = workflowRunState(run);
|
|
178
|
+
task.acceptanceCriteria = [
|
|
179
|
+
...(task.acceptanceCriteria ?? []).filter((criterion) => criterion.id !== `run-${run.id}`),
|
|
180
|
+
{
|
|
181
|
+
id: `run-${run.id}`,
|
|
182
|
+
label: `Workflow ${run.workflowId} ${run.status}`,
|
|
183
|
+
status: run.status === 'completed' ? 'passed' : run.status === 'failed' || run.status === 'canceled' ? 'failed' : 'pending',
|
|
184
|
+
source: 'workflow',
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
task.updatedAt = Date.now();
|
|
188
|
+
this.store.set(task);
|
|
189
|
+
|
|
190
|
+
if (task.taskmasterId && task.state === 'done') {
|
|
191
|
+
this.syncTaskMasterStatus(task.taskmasterId, 'done');
|
|
192
|
+
}
|
|
193
|
+
return task;
|
|
194
|
+
}
|
|
195
|
+
|
|
102
196
|
updateState(taskId: string, state: OrchestrationTask['state']): OrchestrationTask | undefined {
|
|
103
197
|
const task = this.store.get(taskId);
|
|
104
198
|
if (!task) return undefined;
|
|
@@ -3,11 +3,19 @@ export type OrchestrationTaskState = 'todo' | 'in_progress' | 'in_review' | 'don
|
|
|
3
3
|
export interface OrchestrationTask {
|
|
4
4
|
id: string;
|
|
5
5
|
a2aTaskId?: string;
|
|
6
|
+
workflowRunIds?: string[];
|
|
6
7
|
taskmasterId?: string;
|
|
7
8
|
projectId: string;
|
|
8
9
|
title: string;
|
|
9
10
|
description?: string;
|
|
10
11
|
state: OrchestrationTaskState;
|
|
12
|
+
acceptanceCriteria?: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
status: 'pending' | 'passed' | 'failed';
|
|
16
|
+
source: 'taskmaster' | 'workflow';
|
|
17
|
+
}>;
|
|
18
|
+
changedFiles?: string[];
|
|
11
19
|
adapterId?: string;
|
|
12
20
|
adapterSelector?: string;
|
|
13
21
|
workspaceKind?: 'host' | 'worktree' | 'docker';
|
|
@@ -21,6 +29,8 @@ export interface CreateOrchestrationTaskInput {
|
|
|
21
29
|
title: string;
|
|
22
30
|
description?: string;
|
|
23
31
|
taskmasterId?: string;
|
|
32
|
+
acceptanceCriteria?: OrchestrationTask['acceptanceCriteria'];
|
|
33
|
+
changedFiles?: string[];
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export interface DispatchOrchestrationTaskInput {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { WorkflowNodeRun, WorkflowRun } from '@/modules/orchestration/workflows/workflow.types.js';
|
|
2
|
+
import { workflowStore } from '@/modules/orchestration/workflows/workflow-store.js';
|
|
3
|
+
import { orchestrationTaskService } from '@/modules/orchestration/tasks/orchestration-task.service.js';
|
|
4
|
+
import type { OrchestrationTask } from '@/modules/orchestration/tasks/orchestration-task.types.js';
|
|
5
|
+
|
|
6
|
+
export const PIXCODE_TASK_RUN_GRAPH_PROTOCOL = 'pixcode.task-run-graph.v1';
|
|
7
|
+
|
|
8
|
+
export type TaskRunGraphCriterionStatus = 'pending' | 'passed' | 'failed';
|
|
9
|
+
|
|
10
|
+
export interface TaskRunGraphCriterion {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
status: TaskRunGraphCriterionStatus;
|
|
14
|
+
source: 'taskmaster' | 'workflow';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TaskRunGraphRunSummary {
|
|
18
|
+
id: string;
|
|
19
|
+
workflowId: string;
|
|
20
|
+
status: WorkflowRun['status'];
|
|
21
|
+
startedAt: number;
|
|
22
|
+
finishedAt?: number;
|
|
23
|
+
taskmasterId?: string;
|
|
24
|
+
orchestrationTaskId?: string;
|
|
25
|
+
changedFiles: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TaskRunGraph {
|
|
29
|
+
protocol: typeof PIXCODE_TASK_RUN_GRAPH_PROTOCOL;
|
|
30
|
+
projectId: string;
|
|
31
|
+
taskmasterId?: string;
|
|
32
|
+
orchestrationTaskId?: string;
|
|
33
|
+
workflowRuns: TaskRunGraphRunSummary[];
|
|
34
|
+
changedFiles: string[];
|
|
35
|
+
acceptanceCriteria: TaskRunGraphCriterion[];
|
|
36
|
+
status: {
|
|
37
|
+
totalRuns: number;
|
|
38
|
+
completedRuns: number;
|
|
39
|
+
failedRuns: number;
|
|
40
|
+
passedCriteria: number;
|
|
41
|
+
failedCriteria: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readString(value: unknown): string | undefined {
|
|
46
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
|
50
|
+
return value && typeof value === 'object' ? value as Record<string, unknown> : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function uniqueStrings(values: Array<string | undefined>): string[] {
|
|
54
|
+
return [...new Set(values.filter((value): value is string => Boolean(value?.trim())))]
|
|
55
|
+
.sort((a, b) => a.localeCompare(b));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function taskStatusPassed(status: unknown): boolean {
|
|
59
|
+
const normalized = String(status ?? '').toLocaleLowerCase('en');
|
|
60
|
+
return normalized === 'done' || normalized === 'completed';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function taskStatusFailed(status: unknown): boolean {
|
|
64
|
+
const normalized = String(status ?? '').toLocaleLowerCase('en');
|
|
65
|
+
return normalized === 'failed' || normalized === 'blocked' || normalized === 'cancelled' || normalized === 'canceled';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function criterionStatus(status: unknown): TaskRunGraphCriterionStatus {
|
|
69
|
+
if (taskStatusPassed(status)) return 'passed';
|
|
70
|
+
if (taskStatusFailed(status)) return 'failed';
|
|
71
|
+
return 'pending';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function taskmasterAcceptanceCriteria(taskmasterTask: Record<string, unknown> | undefined): TaskRunGraphCriterion[] {
|
|
75
|
+
if (!taskmasterTask) return [];
|
|
76
|
+
|
|
77
|
+
const criteria: TaskRunGraphCriterion[] = [];
|
|
78
|
+
const testStrategy = readString(taskmasterTask.testStrategy) ?? readString(taskmasterTask.test_strategy);
|
|
79
|
+
if (testStrategy) {
|
|
80
|
+
criteria.push({
|
|
81
|
+
id: 'test-strategy',
|
|
82
|
+
label: testStrategy,
|
|
83
|
+
status: criterionStatus(taskmasterTask.status),
|
|
84
|
+
source: 'taskmaster',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const subtasks = Array.isArray(taskmasterTask.subtasks) ? taskmasterTask.subtasks : [];
|
|
89
|
+
subtasks.forEach((subtask, index) => {
|
|
90
|
+
const record = readRecord(subtask);
|
|
91
|
+
if (!record) return;
|
|
92
|
+
const title = readString(record.title);
|
|
93
|
+
if (!title) return;
|
|
94
|
+
criteria.push({
|
|
95
|
+
id: `subtask-${readString(record.id) ?? index + 1}`,
|
|
96
|
+
label: title,
|
|
97
|
+
status: criterionStatus(record.status),
|
|
98
|
+
source: 'taskmaster',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return criteria;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function metadataTaskmasterId(run: WorkflowRun): string | undefined {
|
|
106
|
+
return readString(run.metadata?.taskmasterId)
|
|
107
|
+
?? readString(readRecord(run.metadata?.taskGraph)?.taskmasterId)
|
|
108
|
+
?? readString(readRecord(run.metadata?.replay)?.taskmasterId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function metadataOrchestrationTaskId(run: WorkflowRun): string | undefined {
|
|
112
|
+
return readString(run.metadata?.orchestrationTaskId)
|
|
113
|
+
?? readString(readRecord(run.metadata?.taskGraph)?.orchestrationTaskId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function artifactChangedFiles(node: WorkflowNodeRun): string[] {
|
|
117
|
+
const files: string[] = [];
|
|
118
|
+
|
|
119
|
+
if (node.handoffArtifact?.changedFiles) {
|
|
120
|
+
files.push(...node.handoffArtifact.changedFiles);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const artifact of node.artifacts ?? []) {
|
|
124
|
+
const data = readRecord(artifact.data);
|
|
125
|
+
const metadata = readRecord(artifact.metadata);
|
|
126
|
+
const candidates = [
|
|
127
|
+
readString(metadata?.path),
|
|
128
|
+
readString(metadata?.file),
|
|
129
|
+
readString(data?.path),
|
|
130
|
+
readString(data?.file),
|
|
131
|
+
];
|
|
132
|
+
files.push(...candidates.filter((value): value is string => Boolean(value)));
|
|
133
|
+
|
|
134
|
+
for (const key of ['files', 'changedFiles']) {
|
|
135
|
+
const value = data?.[key] ?? metadata?.[key];
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
files.push(...value.map((entry) => readString(entry)).filter((entry): entry is string => Boolean(entry)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return uniqueStrings(files);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function changedFilesFromWorkflowRun(run: WorkflowRun): string[] {
|
|
146
|
+
return uniqueStrings(run.nodeRuns.flatMap((node) => artifactChangedFiles(node)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function workflowRunMatchesTask(run: WorkflowRun, task: OrchestrationTask | undefined, taskmasterId: string | undefined): boolean {
|
|
150
|
+
const runTaskmasterId = metadataTaskmasterId(run);
|
|
151
|
+
const runOrchestrationTaskId = metadataOrchestrationTaskId(run);
|
|
152
|
+
if (taskmasterId && runTaskmasterId === taskmasterId) return true;
|
|
153
|
+
if (task?.id && runOrchestrationTaskId === task.id) return true;
|
|
154
|
+
if (task?.workflowRunIds?.includes(run.id)) return true;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function runSummary(run: WorkflowRun): TaskRunGraphRunSummary {
|
|
159
|
+
return {
|
|
160
|
+
id: run.id,
|
|
161
|
+
workflowId: run.workflowId,
|
|
162
|
+
status: run.status,
|
|
163
|
+
startedAt: run.startedAt,
|
|
164
|
+
finishedAt: run.finishedAt,
|
|
165
|
+
taskmasterId: metadataTaskmasterId(run),
|
|
166
|
+
orchestrationTaskId: metadataOrchestrationTaskId(run),
|
|
167
|
+
changedFiles: changedFilesFromWorkflowRun(run),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function buildTaskRunGraph({
|
|
172
|
+
projectId,
|
|
173
|
+
taskmasterId,
|
|
174
|
+
taskmasterTask,
|
|
175
|
+
}: {
|
|
176
|
+
projectId: string;
|
|
177
|
+
taskmasterId?: string;
|
|
178
|
+
taskmasterTask?: Record<string, unknown>;
|
|
179
|
+
}): TaskRunGraph {
|
|
180
|
+
const orchestrationTask = taskmasterId
|
|
181
|
+
? orchestrationTaskService.list(projectId).find((task) => task.taskmasterId === taskmasterId)
|
|
182
|
+
: undefined;
|
|
183
|
+
const workflowRuns = workflowStore
|
|
184
|
+
.listRuns()
|
|
185
|
+
.filter((run) => workflowRunMatchesTask(run, orchestrationTask, taskmasterId))
|
|
186
|
+
.map(runSummary);
|
|
187
|
+
const workflowCriteria: TaskRunGraphCriterion[] = workflowRuns.map((run) => ({
|
|
188
|
+
id: `run-${run.id}`,
|
|
189
|
+
label: `Workflow ${run.workflowId} ${run.status}`,
|
|
190
|
+
status: run.status === 'completed' ? 'passed' : run.status === 'failed' || run.status === 'canceled' ? 'failed' : 'pending',
|
|
191
|
+
source: 'workflow',
|
|
192
|
+
}));
|
|
193
|
+
const acceptanceCriteria = [
|
|
194
|
+
...taskmasterAcceptanceCriteria(taskmasterTask),
|
|
195
|
+
...(orchestrationTask?.acceptanceCriteria ?? []),
|
|
196
|
+
...workflowCriteria,
|
|
197
|
+
];
|
|
198
|
+
const changedFiles = uniqueStrings([
|
|
199
|
+
...(orchestrationTask?.changedFiles ?? []),
|
|
200
|
+
...workflowRuns.flatMap((run) => run.changedFiles),
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
protocol: PIXCODE_TASK_RUN_GRAPH_PROTOCOL,
|
|
205
|
+
projectId,
|
|
206
|
+
taskmasterId,
|
|
207
|
+
orchestrationTaskId: orchestrationTask?.id,
|
|
208
|
+
workflowRuns,
|
|
209
|
+
changedFiles,
|
|
210
|
+
acceptanceCriteria,
|
|
211
|
+
status: {
|
|
212
|
+
totalRuns: workflowRuns.length,
|
|
213
|
+
completedRuns: workflowRuns.filter((run) => run.status === 'completed').length,
|
|
214
|
+
failedRuns: workflowRuns.filter((run) => run.status === 'failed' || run.status === 'canceled').length,
|
|
215
|
+
passedCriteria: acceptanceCriteria.filter((criterion) => criterion.status === 'passed').length,
|
|
216
|
+
failedCriteria: acceptanceCriteria.filter((criterion) => criterion.status === 'failed').length,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { WorkflowNode, WorkflowRun } from '@/modules/orchestration/workflows/workflow.types.js';
|
|
2
|
+
|
|
3
|
+
export const PIXCODE_FALLBACK_POLICY_PROTOCOL = 'pixcode.fallback-policy.v1';
|
|
4
|
+
|
|
5
|
+
export type WorkflowFallbackTrigger = 'provider_failure' | 'timeout' | 'tool_failure' | 'invalid_output';
|
|
6
|
+
|
|
7
|
+
export interface WorkflowFallbackPolicy {
|
|
8
|
+
protocol: typeof PIXCODE_FALLBACK_POLICY_PROTOCOL;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
triggers: WorkflowFallbackTrigger[];
|
|
11
|
+
maxFallbacksPerRun: number;
|
|
12
|
+
requireDifferentAgent: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WorkflowFallbackDecision {
|
|
16
|
+
shouldFallback: boolean;
|
|
17
|
+
trigger: WorkflowFallbackTrigger;
|
|
18
|
+
reason: string;
|
|
19
|
+
policy: WorkflowFallbackPolicy;
|
|
20
|
+
skippedReason?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TRIGGERS: WorkflowFallbackTrigger[] = [
|
|
24
|
+
'provider_failure',
|
|
25
|
+
'timeout',
|
|
26
|
+
'tool_failure',
|
|
27
|
+
'invalid_output',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_WORKFLOW_FALLBACK_POLICY: WorkflowFallbackPolicy = {
|
|
31
|
+
protocol: PIXCODE_FALLBACK_POLICY_PROTOCOL,
|
|
32
|
+
enabled: true,
|
|
33
|
+
triggers: DEFAULT_TRIGGERS,
|
|
34
|
+
maxFallbacksPerRun: 3,
|
|
35
|
+
requireDifferentAgent: true,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
|
39
|
+
return value && typeof value === 'object' ? value as Record<string, unknown> : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readBoolean(value: unknown): boolean | undefined {
|
|
43
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readNumber(value: unknown): number | undefined {
|
|
47
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isWorkflowFallbackTrigger(value: unknown): value is WorkflowFallbackTrigger {
|
|
51
|
+
return value === 'provider_failure'
|
|
52
|
+
|| value === 'timeout'
|
|
53
|
+
|| value === 'tool_failure'
|
|
54
|
+
|| value === 'invalid_output';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readFallbackTriggers(value: unknown): WorkflowFallbackTrigger[] | undefined {
|
|
58
|
+
if (!Array.isArray(value)) return undefined;
|
|
59
|
+
const triggers = value.filter(isWorkflowFallbackTrigger);
|
|
60
|
+
return triggers.length > 0 ? [...new Set(triggers)] : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readWorkflowFallbackPolicy(metadata?: Record<string, unknown>): WorkflowFallbackPolicy {
|
|
64
|
+
const settings = readRecord(metadata?.settings) ?? {};
|
|
65
|
+
const configured = readRecord(settings.fallbackPolicy) ?? {};
|
|
66
|
+
const maxFallbacksPerRun = readNumber(configured.maxFallbacksPerRun);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
protocol: PIXCODE_FALLBACK_POLICY_PROTOCOL,
|
|
70
|
+
enabled: readBoolean(configured.enabled) ?? DEFAULT_WORKFLOW_FALLBACK_POLICY.enabled,
|
|
71
|
+
triggers: readFallbackTriggers(configured.triggers) ?? DEFAULT_WORKFLOW_FALLBACK_POLICY.triggers,
|
|
72
|
+
maxFallbacksPerRun: maxFallbacksPerRun === undefined
|
|
73
|
+
? DEFAULT_WORKFLOW_FALLBACK_POLICY.maxFallbacksPerRun
|
|
74
|
+
: Math.max(0, Math.min(8, Math.round(maxFallbacksPerRun))),
|
|
75
|
+
requireDifferentAgent: readBoolean(configured.requireDifferentAgent)
|
|
76
|
+
?? DEFAULT_WORKFLOW_FALLBACK_POLICY.requireDifferentAgent,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function classifyWorkflowFailure(
|
|
81
|
+
reason: string,
|
|
82
|
+
explicitTrigger?: WorkflowFallbackTrigger,
|
|
83
|
+
): WorkflowFallbackTrigger {
|
|
84
|
+
if (explicitTrigger) return explicitTrigger;
|
|
85
|
+
|
|
86
|
+
const text = reason.toLocaleLowerCase('en');
|
|
87
|
+
if (/timed out|timeout|deadline/u.test(text)) return 'timeout';
|
|
88
|
+
if (/invalid (handoff|output|artifact|json|schema)|parse|protocol/u.test(text)) return 'invalid_output';
|
|
89
|
+
if (
|
|
90
|
+
/tool|command|shell|exit code|permission|file write|write failed|network|fetch|curl|wget|gh |npm |git /u
|
|
91
|
+
.test(text)
|
|
92
|
+
) {
|
|
93
|
+
return 'tool_failure';
|
|
94
|
+
}
|
|
95
|
+
return 'provider_failure';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fallbackEventCount(run: WorkflowRun): number {
|
|
99
|
+
return Array.isArray(run.metadata?.fallbackEvents) ? run.metadata.fallbackEvents.length : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveWorkflowFallbackDecision({
|
|
103
|
+
run,
|
|
104
|
+
node,
|
|
105
|
+
reason,
|
|
106
|
+
trigger,
|
|
107
|
+
fallbackAgentInstanceId,
|
|
108
|
+
}: {
|
|
109
|
+
run: WorkflowRun;
|
|
110
|
+
node: WorkflowNode;
|
|
111
|
+
reason: string;
|
|
112
|
+
trigger?: WorkflowFallbackTrigger;
|
|
113
|
+
fallbackAgentInstanceId?: string;
|
|
114
|
+
}): WorkflowFallbackDecision {
|
|
115
|
+
const fallbackTrigger = classifyWorkflowFailure(reason, trigger);
|
|
116
|
+
const policy = readWorkflowFallbackPolicy(run.metadata);
|
|
117
|
+
|
|
118
|
+
if (!policy.enabled) {
|
|
119
|
+
return {
|
|
120
|
+
shouldFallback: false,
|
|
121
|
+
trigger: fallbackTrigger,
|
|
122
|
+
reason,
|
|
123
|
+
policy,
|
|
124
|
+
skippedReason: 'Fallback policy is disabled.',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (!policy.triggers.includes(fallbackTrigger)) {
|
|
128
|
+
return {
|
|
129
|
+
shouldFallback: false,
|
|
130
|
+
trigger: fallbackTrigger,
|
|
131
|
+
reason,
|
|
132
|
+
policy,
|
|
133
|
+
skippedReason: `Fallback trigger ${fallbackTrigger} is not enabled.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (fallbackEventCount(run) >= policy.maxFallbacksPerRun) {
|
|
137
|
+
return {
|
|
138
|
+
shouldFallback: false,
|
|
139
|
+
trigger: fallbackTrigger,
|
|
140
|
+
reason,
|
|
141
|
+
policy,
|
|
142
|
+
skippedReason: `Fallback limit ${policy.maxFallbacksPerRun} reached.`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (policy.requireDifferentAgent && fallbackAgentInstanceId && fallbackAgentInstanceId === node.agentInstanceId) {
|
|
146
|
+
return {
|
|
147
|
+
shouldFallback: false,
|
|
148
|
+
trigger: fallbackTrigger,
|
|
149
|
+
reason,
|
|
150
|
+
policy,
|
|
151
|
+
skippedReason: 'Fallback agent must be different from the failed agent.',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
shouldFallback: true,
|
|
157
|
+
trigger: fallbackTrigger,
|
|
158
|
+
reason,
|
|
159
|
+
policy,
|
|
160
|
+
};
|
|
161
|
+
}
|