@pixelbyte-software/pixcode 1.42.1 → 1.42.2
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-CMeiCqQf.js} +182 -182
- package/dist/index.html +1 -1
- 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 +47 -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/package.json +1 -1
- package/scripts/smoke/workflow-fallback-replay.mjs +56 -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 +105 -6
- 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
|
@@ -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');
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Workflow,
|
|
3
|
+
WorkflowNode,
|
|
4
|
+
WorkflowNodeRun,
|
|
5
|
+
WorkflowRun,
|
|
6
|
+
} from '@/modules/orchestration/workflows/workflow.types.js';
|
|
7
|
+
import { redactTraceText } from '@/modules/orchestration/workflows/workflow-trace.js';
|
|
8
|
+
|
|
9
|
+
export const PIXCODE_REPLAY_PROTOCOL = 'pixcode.workflow-replay.v1';
|
|
10
|
+
|
|
11
|
+
export type WorkflowReplayScope = 'run' | 'node';
|
|
12
|
+
export type WorkflowReplaySafetyKind = 'file-write' | 'shell' | 'network';
|
|
13
|
+
|
|
14
|
+
export interface WorkflowReplayOperation {
|
|
15
|
+
kind: WorkflowReplaySafetyKind;
|
|
16
|
+
nodeId?: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkflowReplayPlan {
|
|
21
|
+
protocol: typeof PIXCODE_REPLAY_PROTOCOL;
|
|
22
|
+
sourceRunId: string;
|
|
23
|
+
sourceWorkflowId: string;
|
|
24
|
+
scope: WorkflowReplayScope;
|
|
25
|
+
fromNodeId?: string;
|
|
26
|
+
selectedNodeIds: string[];
|
|
27
|
+
requiresApproval: boolean;
|
|
28
|
+
approvalReasons: string[];
|
|
29
|
+
destructiveOperations: WorkflowReplayOperation[];
|
|
30
|
+
limitations: string[];
|
|
31
|
+
input: string;
|
|
32
|
+
workflow: Workflow;
|
|
33
|
+
metadata: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function safeNodeId(value: string): string {
|
|
37
|
+
return value.replace(/[^a-zA-Z0-9_]+/g, '_').slice(0, 48) || 'node';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
|
41
|
+
return value && typeof value === 'object' ? value as Record<string, unknown> : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function visibleNodes(run: WorkflowRun): WorkflowNodeRun[] {
|
|
45
|
+
return run.nodeRuns.filter((node) => !node.internal);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultReplayNode(run: WorkflowRun): WorkflowNodeRun | undefined {
|
|
49
|
+
return visibleNodes(run).find((node) => node.status === 'failed')
|
|
50
|
+
?? [...visibleNodes(run)].reverse().find((node) => node.status !== 'skipped')
|
|
51
|
+
?? visibleNodes(run)[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function selectReplayNodes(run: WorkflowRun, scope: WorkflowReplayScope, fromNodeId?: string): WorkflowNodeRun[] {
|
|
55
|
+
const nodes = visibleNodes(run);
|
|
56
|
+
if (scope === 'run') return nodes;
|
|
57
|
+
|
|
58
|
+
const requested = fromNodeId
|
|
59
|
+
? nodes.find((node) => node.nodeId === fromNodeId)
|
|
60
|
+
: defaultReplayNode(run);
|
|
61
|
+
return requested ? [requested] : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function compact(value: string | undefined, run: WorkflowRun, maxLength = 1_200): string | undefined {
|
|
65
|
+
return redactTraceText(value, run, maxLength);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function nodeTraceSummary(run: WorkflowRun, node: WorkflowNodeRun): string {
|
|
69
|
+
const artifactTypes = (node.artifacts ?? []).map((artifact) => artifact.type).filter(Boolean);
|
|
70
|
+
return [
|
|
71
|
+
`Step: ${node.agentLabel || node.nodeId}`,
|
|
72
|
+
`Node id: ${node.nodeId}`,
|
|
73
|
+
`Status: ${node.status}`,
|
|
74
|
+
node.stage ? `Stage: ${node.stage}` : undefined,
|
|
75
|
+
node.adapterId ? `Adapter: ${node.adapterId}` : undefined,
|
|
76
|
+
node.model ? `Model: ${node.model}` : undefined,
|
|
77
|
+
node.error ? `Error: ${compact(node.error, run, 800)}` : undefined,
|
|
78
|
+
artifactTypes.length > 0 ? `Artifacts: ${artifactTypes.join(', ')}` : undefined,
|
|
79
|
+
node.outputText ? `Output excerpt:\n${compact(node.outputText, run)}` : undefined,
|
|
80
|
+
].filter(Boolean).join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function replayTraceSummary(run: WorkflowRun, nodes: WorkflowNodeRun[]): string {
|
|
84
|
+
return nodes.map((node) => nodeTraceSummary(run, node)).join('\n\n---\n\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pushOperation(
|
|
88
|
+
operations: WorkflowReplayOperation[],
|
|
89
|
+
kind: WorkflowReplaySafetyKind,
|
|
90
|
+
nodeId: string | undefined,
|
|
91
|
+
summary: string,
|
|
92
|
+
): void {
|
|
93
|
+
if (operations.some((operation) =>
|
|
94
|
+
operation.kind === kind && operation.nodeId === nodeId && operation.summary === summary,
|
|
95
|
+
)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
operations.push({ kind, nodeId, summary });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function detectReplayOperations(run: WorkflowRun, nodes: WorkflowNodeRun[]): WorkflowReplayOperation[] {
|
|
102
|
+
const operations: WorkflowReplayOperation[] = [];
|
|
103
|
+
|
|
104
|
+
for (const node of nodes) {
|
|
105
|
+
for (const artifact of node.artifacts ?? []) {
|
|
106
|
+
if (artifact.type === 'file-diff') {
|
|
107
|
+
pushOperation(operations, 'file-write', node.nodeId, 'Prior step produced a file diff artifact.');
|
|
108
|
+
}
|
|
109
|
+
if (artifact.type === 'command-output') {
|
|
110
|
+
pushOperation(operations, 'shell', node.nodeId, 'Prior step produced command output.');
|
|
111
|
+
}
|
|
112
|
+
const text = [artifact.text, artifact.data ? JSON.stringify(artifact.data) : undefined]
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.join('\n')
|
|
115
|
+
.toLocaleLowerCase('en');
|
|
116
|
+
if (/https?:\/\/|curl |wget |gh |npm publish|npm install|git push|ssh /u.test(text)) {
|
|
117
|
+
pushOperation(operations, 'network', node.nodeId, 'Prior artifact references a network-capable operation.');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const text = [node.outputText, node.error, node.promptPreview].filter(Boolean).join('\n').toLocaleLowerCase('en');
|
|
122
|
+
if (/apply_patch|write file|file write|modified files|changed files/u.test(text)) {
|
|
123
|
+
pushOperation(operations, 'file-write', node.nodeId, 'Prior step text references file-write activity.');
|
|
124
|
+
}
|
|
125
|
+
if (/shell|command|terminal|npm run|node |python |php |go test|cargo |make |exit code/u.test(text)) {
|
|
126
|
+
pushOperation(operations, 'shell', node.nodeId, 'Prior step text references shell execution.');
|
|
127
|
+
}
|
|
128
|
+
if (/https?:\/\/|curl |wget |gh |npm publish|npm install|git push|ssh |network/u.test(text)) {
|
|
129
|
+
pushOperation(operations, 'network', node.nodeId, 'Prior step text references a network-capable operation.');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return operations;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function replayNodeFromRunNode(
|
|
137
|
+
node: WorkflowNodeRun,
|
|
138
|
+
index: number,
|
|
139
|
+
previousReplayNodeId: string | undefined,
|
|
140
|
+
traceSummary: string,
|
|
141
|
+
limitations: string[],
|
|
142
|
+
requiresApproval: boolean,
|
|
143
|
+
): WorkflowNode {
|
|
144
|
+
const replayNodeId = `replay_${index + 1}_${safeNodeId(node.nodeId)}`;
|
|
145
|
+
return {
|
|
146
|
+
id: replayNodeId,
|
|
147
|
+
adapterId: node.adapterId || 'claude-code',
|
|
148
|
+
agentInstanceId: node.agentInstanceId,
|
|
149
|
+
agentLabel: node.agentLabel ? `${node.agentLabel} Replay` : 'Replay agent',
|
|
150
|
+
assignment: node.assignment ? `Replay: ${node.assignment}` : `Replay source node ${node.nodeId}`,
|
|
151
|
+
stage: node.stage ? `replay_${node.stage}` : 'replay',
|
|
152
|
+
model: node.model,
|
|
153
|
+
permissionMode: node.permissionMode === 'bypassPermissions' ? 'default' : node.permissionMode,
|
|
154
|
+
timeoutMs: node.timeoutMs,
|
|
155
|
+
inputs: previousReplayNodeId ? [previousReplayNodeId] : [],
|
|
156
|
+
output: 'both',
|
|
157
|
+
onFail: 'abort',
|
|
158
|
+
prompt: [
|
|
159
|
+
'This is a Pixcode workflow replay run.',
|
|
160
|
+
`Replay protocol: ${PIXCODE_REPLAY_PROTOCOL}`,
|
|
161
|
+
`Source node: ${node.nodeId}`,
|
|
162
|
+
requiresApproval
|
|
163
|
+
? 'Replay safety review found prior shell, network, or file-write activity. Do not repeat any such action unless the current CLI permission flow asks for and receives user approval.'
|
|
164
|
+
: 'Replay safety review did not find prior shell, network, or file-write artifacts, but still avoid destructive actions unless they are required and approved.',
|
|
165
|
+
'Use the trace summary to continue from the failure or inspect the run. Do not expose secrets, local-only paths, raw tool protocol, or irrelevant logs.',
|
|
166
|
+
`Known limitations:\n- ${limitations.join('\n- ')}`,
|
|
167
|
+
`Trace summary:\n${traceSummary}`,
|
|
168
|
+
`Original step prompt:\n${node.promptPreview || '(No source prompt was stored.)'}`,
|
|
169
|
+
].join('\n\n'),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function buildWorkflowReplayPlan(
|
|
174
|
+
run: WorkflowRun,
|
|
175
|
+
options: {
|
|
176
|
+
scope?: WorkflowReplayScope;
|
|
177
|
+
fromNodeId?: string;
|
|
178
|
+
} = {},
|
|
179
|
+
): WorkflowReplayPlan {
|
|
180
|
+
const scope = options.scope ?? 'node';
|
|
181
|
+
const nodes = selectReplayNodes(run, scope, options.fromNodeId);
|
|
182
|
+
if (nodes.length === 0) {
|
|
183
|
+
throw new Error('No replayable workflow steps were found.');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const limitations = [
|
|
187
|
+
'Replay uses stored run traces, prompt previews, messages, and artifacts; it cannot reproduce hidden provider state.',
|
|
188
|
+
'Replay reconstructs selected steps as a new workflow run instead of mutating the source run.',
|
|
189
|
+
'Shell, network, and file-write actions stay under the current CLI permission flow and require explicit replay approval when detected.',
|
|
190
|
+
];
|
|
191
|
+
const destructiveOperations = detectReplayOperations(run, nodes);
|
|
192
|
+
const requiresApproval = destructiveOperations.length > 0;
|
|
193
|
+
const traceSummary = replayTraceSummary(run, nodes);
|
|
194
|
+
const replayNodes = nodes.reduce<WorkflowNode[]>((accumulator, node, index) => {
|
|
195
|
+
const previousReplayNodeId = accumulator[accumulator.length - 1]?.id;
|
|
196
|
+
accumulator.push(replayNodeFromRunNode(
|
|
197
|
+
node,
|
|
198
|
+
index,
|
|
199
|
+
scope === 'run' ? previousReplayNodeId : undefined,
|
|
200
|
+
traceSummary,
|
|
201
|
+
limitations,
|
|
202
|
+
requiresApproval,
|
|
203
|
+
));
|
|
204
|
+
return accumulator;
|
|
205
|
+
}, []);
|
|
206
|
+
const settings = readRecord(run.metadata?.settings) ?? {};
|
|
207
|
+
const replayMetadata = {
|
|
208
|
+
protocol: PIXCODE_REPLAY_PROTOCOL,
|
|
209
|
+
sourceRunId: run.id,
|
|
210
|
+
sourceWorkflowId: run.workflowId,
|
|
211
|
+
scope,
|
|
212
|
+
fromNodeId: options.fromNodeId,
|
|
213
|
+
selectedNodeIds: nodes.map((node) => node.nodeId),
|
|
214
|
+
requiresApproval,
|
|
215
|
+
destructiveOperations,
|
|
216
|
+
limitations,
|
|
217
|
+
createdAt: Date.now(),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
protocol: PIXCODE_REPLAY_PROTOCOL,
|
|
222
|
+
sourceRunId: run.id,
|
|
223
|
+
sourceWorkflowId: run.workflowId,
|
|
224
|
+
scope,
|
|
225
|
+
fromNodeId: options.fromNodeId,
|
|
226
|
+
selectedNodeIds: nodes.map((node) => node.nodeId),
|
|
227
|
+
requiresApproval,
|
|
228
|
+
approvalReasons: destructiveOperations.map((operation) =>
|
|
229
|
+
`${operation.kind}${operation.nodeId ? ` in ${operation.nodeId}` : ''}: ${operation.summary}`,
|
|
230
|
+
),
|
|
231
|
+
destructiveOperations,
|
|
232
|
+
limitations,
|
|
233
|
+
input: [
|
|
234
|
+
`Replay ${scope === 'run' ? 'full workflow run' : 'workflow step'} from source run ${run.id}.`,
|
|
235
|
+
run.input ? `Original request:\n${compact(run.input, run, 2_000)}` : undefined,
|
|
236
|
+
].filter(Boolean).join('\n\n'),
|
|
237
|
+
workflow: {
|
|
238
|
+
id: `${run.workflowId}_replay`,
|
|
239
|
+
name: `Replay ${run.workflowId}`,
|
|
240
|
+
description: 'Replay generated from stored Pixcode workflow trace data.',
|
|
241
|
+
trigger: 'manual',
|
|
242
|
+
nodes: replayNodes,
|
|
243
|
+
},
|
|
244
|
+
metadata: {
|
|
245
|
+
...run.metadata,
|
|
246
|
+
workflowName: `Replay: ${String(run.metadata?.workflowName ?? run.workflowId)}`,
|
|
247
|
+
replay: replayMetadata,
|
|
248
|
+
settings: {
|
|
249
|
+
...settings,
|
|
250
|
+
replayMode: true,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -16,6 +16,11 @@ import {
|
|
|
16
16
|
buildWorkflowContextPacket,
|
|
17
17
|
formatContextPacketForPrompt,
|
|
18
18
|
} from '@/modules/orchestration/workflows/context-packet.js';
|
|
19
|
+
import {
|
|
20
|
+
type WorkflowFallbackTrigger,
|
|
21
|
+
classifyWorkflowFailure,
|
|
22
|
+
resolveWorkflowFallbackDecision,
|
|
23
|
+
} from '@/modules/orchestration/workflows/workflow-fallback-policy.js';
|
|
19
24
|
import {
|
|
20
25
|
type ResolvedWorkspaceTarget,
|
|
21
26
|
resolveWorkflowWorkspace,
|
|
@@ -1123,6 +1128,8 @@ function nodeRunFromNode(node: WorkflowNode): WorkflowNodeRun {
|
|
|
1123
1128
|
timeoutMs: node.timeoutMs,
|
|
1124
1129
|
stage: node.stage,
|
|
1125
1130
|
internal: node.internal,
|
|
1131
|
+
fallbackTrigger: node.fallbackTrigger,
|
|
1132
|
+
fallbackSourceNodeId: node.fallbackSourceNodeId,
|
|
1126
1133
|
status: 'queued',
|
|
1127
1134
|
};
|
|
1128
1135
|
}
|
|
@@ -1276,7 +1283,12 @@ class WorkflowRunner {
|
|
|
1276
1283
|
return readAgentAssignments(run.metadata).find((agent) => agent.instanceId === fallbackAgentInstanceId);
|
|
1277
1284
|
}
|
|
1278
1285
|
|
|
1279
|
-
private createFallbackNode(
|
|
1286
|
+
private createFallbackNode(
|
|
1287
|
+
node: WorkflowNode,
|
|
1288
|
+
fallbackAgent: AgentAssignment,
|
|
1289
|
+
reason: string,
|
|
1290
|
+
fallbackTrigger: WorkflowFallbackTrigger,
|
|
1291
|
+
): WorkflowNode {
|
|
1280
1292
|
const fallbackSuffix = safeNodeId(fallbackAgent.instanceId, 'fallback');
|
|
1281
1293
|
return {
|
|
1282
1294
|
...node,
|
|
@@ -1289,9 +1301,12 @@ class WorkflowRunner {
|
|
|
1289
1301
|
model: fallbackAgent.model,
|
|
1290
1302
|
permissionMode: fallbackAgent.permissionMode,
|
|
1291
1303
|
toolsSettings: fallbackAgent.toolsSettings,
|
|
1304
|
+
fallbackTrigger,
|
|
1305
|
+
fallbackSourceNodeId: node.id,
|
|
1292
1306
|
prompt: [
|
|
1293
1307
|
'The previous CLI agent failed on this orchestration step.',
|
|
1294
1308
|
`Failed step: ${node.agentLabel || node.id}`,
|
|
1309
|
+
`Fallback trigger: ${fallbackTrigger}`,
|
|
1295
1310
|
`Failure: ${reason}`,
|
|
1296
1311
|
'Take over the same assignment as the backup CLI. Use the original goal and upstream context.',
|
|
1297
1312
|
'Do not repeat unrelated work; complete the failed step and report what you did.',
|
|
@@ -1301,6 +1316,32 @@ class WorkflowRunner {
|
|
|
1301
1316
|
};
|
|
1302
1317
|
}
|
|
1303
1318
|
|
|
1319
|
+
private recordFallbackSkipped(
|
|
1320
|
+
run: WorkflowRun,
|
|
1321
|
+
node: WorkflowNode,
|
|
1322
|
+
reason: string,
|
|
1323
|
+
fallbackTrigger: WorkflowFallbackTrigger,
|
|
1324
|
+
skippedReason: string,
|
|
1325
|
+
): void {
|
|
1326
|
+
const fallbackSkippedEvents = Array.isArray(run.metadata?.fallbackSkippedEvents)
|
|
1327
|
+
? run.metadata.fallbackSkippedEvents
|
|
1328
|
+
: [];
|
|
1329
|
+
run.metadata = {
|
|
1330
|
+
...run.metadata,
|
|
1331
|
+
fallbackSkippedEvents: [
|
|
1332
|
+
...fallbackSkippedEvents,
|
|
1333
|
+
{
|
|
1334
|
+
nodeId: node.id,
|
|
1335
|
+
trigger: fallbackTrigger,
|
|
1336
|
+
reason,
|
|
1337
|
+
skippedReason,
|
|
1338
|
+
createdAt: Date.now(),
|
|
1339
|
+
},
|
|
1340
|
+
],
|
|
1341
|
+
};
|
|
1342
|
+
workflowStore.setRun(run);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1304
1345
|
private async runFallbackAfterFailure(
|
|
1305
1346
|
node: WorkflowNode,
|
|
1306
1347
|
workflow: Workflow,
|
|
@@ -1309,9 +1350,29 @@ class WorkflowRunner {
|
|
|
1309
1350
|
started: Set<string>,
|
|
1310
1351
|
completed: Set<string>,
|
|
1311
1352
|
reason: string,
|
|
1353
|
+
trigger?: WorkflowFallbackTrigger,
|
|
1312
1354
|
): Promise<boolean> {
|
|
1355
|
+
const fallbackTrigger = classifyWorkflowFailure(reason, trigger);
|
|
1313
1356
|
const fallbackAgent = this.fallbackAgentFor(run, node);
|
|
1314
1357
|
if (!fallbackAgent) {
|
|
1358
|
+
this.recordFallbackSkipped(run, node, reason, fallbackTrigger, 'No fallback agent is configured for this run.');
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
1361
|
+
const decision = resolveWorkflowFallbackDecision({
|
|
1362
|
+
run,
|
|
1363
|
+
node,
|
|
1364
|
+
reason,
|
|
1365
|
+
trigger: fallbackTrigger,
|
|
1366
|
+
fallbackAgentInstanceId: fallbackAgent.instanceId,
|
|
1367
|
+
});
|
|
1368
|
+
if (!decision.shouldFallback) {
|
|
1369
|
+
this.recordFallbackSkipped(
|
|
1370
|
+
run,
|
|
1371
|
+
node,
|
|
1372
|
+
reason,
|
|
1373
|
+
decision.trigger,
|
|
1374
|
+
decision.skippedReason ?? 'Fallback policy skipped this failure.',
|
|
1375
|
+
);
|
|
1315
1376
|
return false;
|
|
1316
1377
|
}
|
|
1317
1378
|
if (workflow.nodes.length + 1 > 64) {
|
|
@@ -1323,7 +1384,7 @@ class WorkflowRunner {
|
|
|
1323
1384
|
return false;
|
|
1324
1385
|
}
|
|
1325
1386
|
|
|
1326
|
-
let fallbackNode = this.createFallbackNode(node, fallbackAgent, reason);
|
|
1387
|
+
let fallbackNode = this.createFallbackNode(node, fallbackAgent, reason, decision.trigger);
|
|
1327
1388
|
let collision = 1;
|
|
1328
1389
|
while (workflow.nodes.some((candidate) => candidate.id === fallbackNode.id)) {
|
|
1329
1390
|
collision += 1;
|
|
@@ -1357,6 +1418,8 @@ class WorkflowRunner {
|
|
|
1357
1418
|
nodeId: node.id,
|
|
1358
1419
|
fallbackNodeId: fallbackNode.id,
|
|
1359
1420
|
fallbackAgentInstanceId: fallbackAgent.instanceId,
|
|
1421
|
+
trigger: decision.trigger,
|
|
1422
|
+
policy: decision.policy,
|
|
1360
1423
|
reason,
|
|
1361
1424
|
startedAt: Date.now(),
|
|
1362
1425
|
},
|
|
@@ -1658,7 +1721,16 @@ class WorkflowRunner {
|
|
|
1658
1721
|
workflowStore.setRun(run);
|
|
1659
1722
|
return;
|
|
1660
1723
|
}
|
|
1661
|
-
if (await this.runFallbackAfterFailure(
|
|
1724
|
+
if (await this.runFallbackAfterFailure(
|
|
1725
|
+
node,
|
|
1726
|
+
workflow,
|
|
1727
|
+
run,
|
|
1728
|
+
outputs,
|
|
1729
|
+
started,
|
|
1730
|
+
completed,
|
|
1731
|
+
nodeRun.error,
|
|
1732
|
+
'provider_failure',
|
|
1733
|
+
)) {
|
|
1662
1734
|
return;
|
|
1663
1735
|
}
|
|
1664
1736
|
if (node.onFail === 'continue') {
|
|
@@ -1710,7 +1782,16 @@ class WorkflowRunner {
|
|
|
1710
1782
|
workflowStore.setRun(run);
|
|
1711
1783
|
return;
|
|
1712
1784
|
}
|
|
1713
|
-
if (await this.runFallbackAfterFailure(
|
|
1785
|
+
if (await this.runFallbackAfterFailure(
|
|
1786
|
+
node,
|
|
1787
|
+
workflow,
|
|
1788
|
+
run,
|
|
1789
|
+
outputs,
|
|
1790
|
+
started,
|
|
1791
|
+
completed,
|
|
1792
|
+
nodeRun.error,
|
|
1793
|
+
'timeout',
|
|
1794
|
+
)) {
|
|
1714
1795
|
return;
|
|
1715
1796
|
}
|
|
1716
1797
|
if (node.onFail === 'continue') {
|
|
@@ -1744,7 +1825,16 @@ class WorkflowRunner {
|
|
|
1744
1825
|
nodeRun.status = 'failed';
|
|
1745
1826
|
nodeRun.error = visibleHandoffError;
|
|
1746
1827
|
workflowStore.setRun(run);
|
|
1747
|
-
if (await this.runFallbackAfterFailure(
|
|
1828
|
+
if (await this.runFallbackAfterFailure(
|
|
1829
|
+
node,
|
|
1830
|
+
workflow,
|
|
1831
|
+
run,
|
|
1832
|
+
outputs,
|
|
1833
|
+
started,
|
|
1834
|
+
completed,
|
|
1835
|
+
visibleHandoffError,
|
|
1836
|
+
'invalid_output',
|
|
1837
|
+
)) {
|
|
1748
1838
|
return;
|
|
1749
1839
|
}
|
|
1750
1840
|
if (node.onFail === 'continue') {
|
|
@@ -1783,7 +1873,16 @@ class WorkflowRunner {
|
|
|
1783
1873
|
workflowStore.setRun(run);
|
|
1784
1874
|
return;
|
|
1785
1875
|
}
|
|
1786
|
-
if (await this.runFallbackAfterFailure(
|
|
1876
|
+
if (await this.runFallbackAfterFailure(
|
|
1877
|
+
node,
|
|
1878
|
+
workflow,
|
|
1879
|
+
run,
|
|
1880
|
+
outputs,
|
|
1881
|
+
started,
|
|
1882
|
+
completed,
|
|
1883
|
+
nodeRun.error,
|
|
1884
|
+
classifyWorkflowFailure(`${nodeRun.error}\n${nodeRun.outputText ?? ''}`),
|
|
1885
|
+
)) {
|
|
1787
1886
|
return;
|
|
1788
1887
|
}
|
|
1789
1888
|
if (node.onFail === 'continue') {
|