@wichayutdew/pi-workflows 1.0.1 → 2.0.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/README.md +312 -223
- package/agents/step.md +4 -11
- package/dist/index.js +2554 -976
- package/examples/mr-comments.workflow.yaml +16 -8
- package/examples/prompts/mr-comments/implement.md +13 -9
- package/examples/prompts/mr-comments/plan.md +44 -15
- package/examples/prompts/mr-comments/verify.md +8 -7
- package/examples/settings.yaml +7 -1
- package/examples/starter-kit/mr-comment.workflow.yaml +149 -0
- package/examples/starter-kit/mr-review.workflow.yaml +130 -0
- package/examples/starter-kit/steps/mr-comment/fetch.md +33 -0
- package/examples/starter-kit/steps/mr-comment/implement.md +38 -0
- package/examples/starter-kit/steps/mr-comment/plan.md +62 -0
- package/examples/starter-kit/steps/mr-comment/publish.md +36 -0
- package/examples/starter-kit/steps/mr-comment/verify.md +35 -0
- package/examples/starter-kit/steps/mr-review/fetch.md +25 -0
- package/examples/starter-kit/steps/mr-review/publish.md +30 -0
- package/examples/starter-kit/steps/mr-review/review.md +69 -0
- package/examples/starter-kit/steps/mr-review/verify.md +24 -0
- package/examples/starter-kit/steps/shared/prepare-workspace.md +57 -0
- package/examples/starter-kit/steps/ticket/implement.md +35 -0
- package/examples/starter-kit/steps/ticket/plan.md +40 -0
- package/examples/starter-kit/steps/ticket/verify.md +29 -0
- package/examples/starter-kit/steps/work/implement.md +34 -0
- package/examples/starter-kit/steps/work/plan.md +38 -0
- package/examples/starter-kit/steps/work/verify.md +30 -0
- package/examples/starter-kit/ticket.workflow.yaml +100 -0
- package/examples/starter-kit/work.workflow.yaml +97 -0
- package/package.json +3 -3
- package/schemas/workflow.schema.json +78 -44
- package/src/command-names.ts +1 -0
- package/src/commands.ts +33 -4
- package/src/config/ceiling.ts +6 -12
- package/src/config/load-workflows.ts +12 -1
- package/src/config/step-digests.ts +19 -0
- package/src/config/types.ts +17 -22
- package/src/config/validation/permissions.ts +5 -66
- package/src/config/validation/prompt.ts +1 -0
- package/src/config/validation/shared.ts +0 -2
- package/src/config/validation/step.ts +106 -2
- package/src/config/validation/workflow.ts +76 -0
- package/src/engine/create-run.ts +3 -0
- package/src/engine/gate-transitions.ts +58 -9
- package/src/engine/reconciliation-history.ts +61 -24
- package/src/engine/run-advance.ts +37 -4
- package/src/engine/run-lifecycle.ts +11 -0
- package/src/engine/run-reconciliation.ts +51 -21
- package/src/engine/run-validation.ts +235 -0
- package/src/engine/run-workflow-validation.ts +191 -0
- package/src/engine/state-types.ts +108 -5
- package/src/engine/state.ts +15 -1
- package/src/engine/step-trace.ts +495 -0
- package/src/engine/transitions.ts +1 -0
- package/src/harness/action-context.ts +31 -6
- package/src/harness/core-actions.ts +30 -1
- package/src/harness/delegation-control-actions.ts +27 -2
- package/src/harness/delegation-failure.ts +42 -9
- package/src/harness/delegation-plan.ts +96 -40
- package/src/harness/delegation-recovery-validation.ts +3 -1
- package/src/harness/delegation-response-actions.ts +73 -3
- package/src/harness/delegation-retry-policy.ts +5 -3
- package/src/harness/dependencies.ts +11 -2
- package/src/harness/gate-submission-action.ts +5 -31
- package/src/harness/lifecycle-actions.ts +4 -5
- package/src/harness/pause-actions.ts +10 -1
- package/src/harness/plannotator-result-actions.ts +20 -11
- package/src/harness/prompt-gate-actions.ts +40 -2
- package/src/harness/resume-action.ts +75 -1
- package/src/harness/start-actions.ts +77 -3
- package/src/harness/status-actions.ts +7 -17
- package/src/harness/step-effects.ts +61 -0
- package/src/harness/step-execution-actions.ts +181 -25
- package/src/harness/step-reporting.ts +168 -0
- package/src/harness/types.ts +1 -0
- package/src/harness/workspace-directory.ts +67 -0
- package/src/harness.ts +37 -9
- package/src/integrations/plannotator-requests.ts +40 -5
- package/src/integrations/subagents/child-policy-sections.ts +71 -84
- package/src/integrations/subagents/child-policy-types.ts +7 -7
- package/src/integrations/subagents/child-policy-validation.ts +10 -4
- package/src/integrations/subagents/child-runtime-completion.ts +1 -0
- package/src/integrations/subagents/child-runtime-files.ts +29 -86
- package/src/integrations/subagents/child-runtime-policy.ts +8 -33
- package/src/integrations/subagents/child-runtime.ts +3 -15
- package/src/integrations/subagents/delegated-result.ts +1 -0
- package/src/integrations/subagents/diagnostic-format.ts +5 -0
- package/src/integrations/subagents/diagnostic-types.ts +7 -1
- package/src/integrations/subagents/diagnostics.ts +2 -0
- package/src/integrations/subagents/failure-correlation.ts +52 -2
- package/src/integrations/subagents/failure-transcript.ts +60 -1
- package/src/integrations/subagents/replay-audit.ts +0 -1
- package/src/integrations/subagents/replay-safety.ts +12 -27
- package/src/integrations/subagents/session-diagnostics.ts +102 -3
- package/src/policy/bash-authorization.ts +2 -21
- package/src/policy/bash-types.ts +0 -5
- package/src/policy/bash.ts +1 -2
- package/src/policy/tool-call-authorization.ts +1 -7
- package/src/preflight.ts +0 -5
- package/src/prompt/retry-task.ts +11 -3
- package/src/prompt/step-contract.ts +10 -56
- package/src/prompt/step-sections.ts +8 -10
- package/src/prompt/step-task.ts +41 -10
- package/src/prompt/template.ts +3 -2
- package/src/runtime/completion-tool.ts +14 -0
- package/src/runtime/main-step-completion.ts +3 -0
- package/src/runtime/main-step-policy.ts +2 -1
- package/src/runtime/main-step-runtime-types.ts +9 -1
- package/src/runtime/main-step-runtime.ts +2 -0
- package/src/runtime/main-step-state.ts +6 -0
- package/src/runtime/main-step-trace.ts +45 -0
- package/src/runtime/step-result.ts +57 -0
- package/src/step-log.ts +178 -0
- package/src/workflow-doctor.ts +254 -0
- package/src/workflow-status/format-status.ts +32 -3
- package/src/workflow-status/render-board.ts +7 -4
- package/src/workflow-status/render-path.ts +51 -24
- package/src/workflow-status/render-step-detail.ts +379 -0
- package/src/workflow-status/render-summary.ts +22 -0
- package/src/workflow-status/transcript-reader.ts +231 -0
- package/src/workflow-status/types.ts +8 -0
- package/src/workflow-status/view.ts +161 -14
- package/src/workflow-status.ts +6 -0
- package/src/policy/approved-command-extraction.ts +0 -71
- package/src/policy/approved-commands.ts +0 -15
- package/src/policy/bash-read-only.ts +0 -161
- package/src/policy/restricted-git.ts +0 -30
- package/src/policy/reviewed-artifact.ts +0 -97
- package/src/policy/reviewed-command-safety.ts +0 -150
- package/src/policy/reviewed-command-shape.ts +0 -49
- package/src/policy/reviewed-repository-contract.ts +0 -138
- package/src/policy/reviewed-repository-cwd.ts +0 -102
|
@@ -2,11 +2,13 @@ import type { LoadedWorkflow } from '../config/types.ts';
|
|
|
2
2
|
import {
|
|
3
3
|
rebuildVisits,
|
|
4
4
|
refreshApprovedGateHistory,
|
|
5
|
-
|
|
5
|
+
retainedReviewedApproval,
|
|
6
|
+
retainedWorkspaceCwd,
|
|
6
7
|
} from './reconciliation-history.ts';
|
|
7
8
|
import type { WorkflowRun } from './state-types.ts';
|
|
8
9
|
import { withRunUpdate } from './transition-helpers.ts';
|
|
9
10
|
import type { ReconcileResult } from './transition-types.ts';
|
|
11
|
+
import { validateRunWorkflowSemantics } from './run-workflow-validation.ts';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Reconciles persisted run state with a reloaded workflow configuration.
|
|
@@ -30,33 +32,61 @@ export const reconcileRun = (
|
|
|
30
32
|
error: `run belongs to "${run.workflowId}", not "${workflow.definition.id}"`,
|
|
31
33
|
};
|
|
32
34
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
const reconciledRun =
|
|
36
|
+
run.workflowDigest === workflow.digest
|
|
37
|
+
? run
|
|
38
|
+
: refreshApprovedGateHistory(run, workflow);
|
|
39
|
+
const changedHistoryIndex =
|
|
40
|
+
run.workflowDigest === workflow.digest
|
|
41
|
+
? -1
|
|
42
|
+
: reconciledRun.history.findIndex(
|
|
43
|
+
(entry) => workflow.stepDigests[entry.stepId] !== entry.stepDigest,
|
|
44
|
+
);
|
|
45
|
+
const changedHistoryEntry =
|
|
46
|
+
changedHistoryIndex >= 0
|
|
47
|
+
? reconciledRun.history[changedHistoryIndex]
|
|
48
|
+
: undefined;
|
|
49
|
+
if (
|
|
50
|
+
changedHistoryEntry &&
|
|
51
|
+
!workflow.definition.steps[changedHistoryEntry.stepId]
|
|
52
|
+
) {
|
|
53
|
+
return {
|
|
54
|
+
changed: true,
|
|
55
|
+
error: 'a completed step was removed; abort or restore the configuration',
|
|
56
|
+
};
|
|
35
57
|
}
|
|
36
|
-
if (
|
|
58
|
+
if (
|
|
59
|
+
changedHistoryIndex < 0 &&
|
|
60
|
+
!workflow.definition.steps[run.currentStepId]
|
|
61
|
+
) {
|
|
37
62
|
return {
|
|
38
63
|
changed: true,
|
|
39
64
|
error: `current step "${run.currentStepId}" was removed; abort or restore the configuration`,
|
|
40
65
|
};
|
|
41
66
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
const semanticError = validateRunWorkflowSemantics(reconciledRun, workflow);
|
|
68
|
+
if (semanticError) {
|
|
69
|
+
return {
|
|
70
|
+
changed: run.workflowDigest !== workflow.digest,
|
|
71
|
+
error: `workflow checkpoint is inconsistent: ${semanticError}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (run.workflowDigest === workflow.digest) {
|
|
75
|
+
return { run, changed: false };
|
|
76
|
+
}
|
|
47
77
|
if (changedHistoryIndex >= 0) {
|
|
48
|
-
const changedEntry =
|
|
49
|
-
if (!changedEntry
|
|
50
|
-
return {
|
|
51
|
-
changed: true,
|
|
52
|
-
error:
|
|
53
|
-
'a completed step was removed; abort or restore the configuration',
|
|
54
|
-
};
|
|
78
|
+
const changedEntry = changedHistoryEntry;
|
|
79
|
+
if (!changedEntry) {
|
|
80
|
+
return { changed: true, error: 'changed history entry is unavailable' };
|
|
55
81
|
}
|
|
56
82
|
|
|
57
83
|
const retainedHistory = reconciledRun.history.slice(0, changedHistoryIndex);
|
|
58
84
|
const restartedStep = changedEntry.stepId;
|
|
59
85
|
const stepHandoff = retainedHistory.at(-1)?.summary ?? '';
|
|
86
|
+
const reviewedApproval = retainedReviewedApproval(
|
|
87
|
+
workflow,
|
|
88
|
+
retainedHistory,
|
|
89
|
+
);
|
|
60
90
|
return {
|
|
61
91
|
changed: true,
|
|
62
92
|
restartedStep,
|
|
@@ -68,12 +98,12 @@ export const reconcileRun = (
|
|
|
68
98
|
currentStepId: restartedStep,
|
|
69
99
|
currentStepDigest: workflow.stepDigests[restartedStep] ?? '',
|
|
70
100
|
history: retainedHistory,
|
|
101
|
+
currentStepAttempts: changedEntry.attempts,
|
|
102
|
+
currentStepOmittedAttempts: changedEntry.omittedAttempts,
|
|
71
103
|
visits: rebuildVisits(retainedHistory, restartedStep),
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
retainedHistory,
|
|
76
|
-
),
|
|
104
|
+
cwd: retainedWorkspaceCwd(reconciledRun, retainedHistory),
|
|
105
|
+
reviewedArtifact: reviewedApproval?.artifact ?? '',
|
|
106
|
+
reviewedFeedback: reviewedApproval?.feedback ?? '',
|
|
77
107
|
stepHandoff,
|
|
78
108
|
lastSummary: stepHandoff,
|
|
79
109
|
pendingGate: undefined,
|
|
@@ -1,23 +1,220 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
1
2
|
import {
|
|
3
|
+
MAX_RESUME_INPUT_CHARS,
|
|
4
|
+
MAX_STEP_TRACE_ARTIFACT_CHARS,
|
|
5
|
+
MAX_STEP_TRACE_ATTEMPTS,
|
|
6
|
+
MAX_STEP_TRACE_LOG_CHARS,
|
|
7
|
+
MAX_STEP_TRACE_LOG_EVENT_CHARS,
|
|
8
|
+
MAX_STEP_TRACE_LOG_EVENTS,
|
|
9
|
+
MAX_STEP_TRACE_SUMMARY_CHARS,
|
|
10
|
+
MAX_STEP_TRACE_TASK_CHARS,
|
|
11
|
+
MAX_WORKFLOW_TRACE_CHARS,
|
|
2
12
|
RUN_STATE_VERSION,
|
|
13
|
+
type GateApproval,
|
|
3
14
|
type GateResolution,
|
|
4
15
|
type PendingGate,
|
|
16
|
+
type StepAttemptResult,
|
|
17
|
+
type StepExecutionAttempt,
|
|
18
|
+
type StepGateDecision,
|
|
5
19
|
type StepHistoryEntry,
|
|
6
20
|
type WorkflowRun,
|
|
7
21
|
type WorkflowRunStatus,
|
|
8
22
|
} from './state-types.ts';
|
|
23
|
+
import { workflowTraceChars } from './step-trace.ts';
|
|
9
24
|
|
|
10
25
|
type UnknownRecord = Readonly<Record<string, unknown>>;
|
|
11
26
|
|
|
12
27
|
const isRecord = (value: unknown): value is UnknownRecord =>
|
|
13
28
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
29
|
|
|
30
|
+
const isAbsoluteCwd = (value: unknown): value is string =>
|
|
31
|
+
typeof value === 'string' &&
|
|
32
|
+
value.length > 0 &&
|
|
33
|
+
isAbsolute(value) &&
|
|
34
|
+
!value.includes('\0');
|
|
35
|
+
|
|
36
|
+
const isGateApproval = (value: unknown): value is GateApproval =>
|
|
37
|
+
isRecord(value) &&
|
|
38
|
+
typeof value.requestId === 'string' &&
|
|
39
|
+
value.requestId.length > 0 &&
|
|
40
|
+
typeof value.artifact === 'string' &&
|
|
41
|
+
value.artifact.trim().length > 0 &&
|
|
42
|
+
typeof value.feedback === 'string' &&
|
|
43
|
+
typeof value.stepStructuralDigest === 'string' &&
|
|
44
|
+
value.stepStructuralDigest.length > 0;
|
|
45
|
+
|
|
46
|
+
const isStepAttemptResult = (value: unknown): value is StepAttemptResult =>
|
|
47
|
+
isRecord(value) &&
|
|
48
|
+
typeof value.outcome === 'string' &&
|
|
49
|
+
typeof value.summary === 'string' &&
|
|
50
|
+
value.summary.length <= MAX_STEP_TRACE_SUMMARY_CHARS &&
|
|
51
|
+
(value.summaryTruncated === undefined || value.summaryTruncated === true) &&
|
|
52
|
+
(value.artifact === undefined || typeof value.artifact === 'string') &&
|
|
53
|
+
(typeof value.artifact !== 'string' ||
|
|
54
|
+
value.artifact.length <= MAX_STEP_TRACE_ARTIFACT_CHARS) &&
|
|
55
|
+
(value.artifactTruncated === undefined || value.artifactTruncated === true) &&
|
|
56
|
+
!(value.artifactTruncated === true && value.artifact === undefined) &&
|
|
57
|
+
(value.workspaceCwd === undefined || isAbsoluteCwd(value.workspaceCwd));
|
|
58
|
+
|
|
59
|
+
const isStepGateDecision = (value: unknown): value is StepGateDecision =>
|
|
60
|
+
isRecord(value) &&
|
|
61
|
+
(value.provider === 'prompt' || value.provider === 'plannotator') &&
|
|
62
|
+
typeof value.requestId === 'string' &&
|
|
63
|
+
value.requestId.length > 0 &&
|
|
64
|
+
typeof value.approved === 'boolean' &&
|
|
65
|
+
typeof value.feedback === 'string' &&
|
|
66
|
+
value.feedback.length <= MAX_STEP_TRACE_SUMMARY_CHARS &&
|
|
67
|
+
(value.feedbackTruncated === undefined || value.feedbackTruncated === true) &&
|
|
68
|
+
typeof value.resolvedAt === 'number' &&
|
|
69
|
+
(value.reviewId === undefined || typeof value.reviewId === 'string');
|
|
70
|
+
|
|
71
|
+
const isSafeTraceIdentityField = (value: unknown): value is string =>
|
|
72
|
+
typeof value === 'string' &&
|
|
73
|
+
value.length > 0 &&
|
|
74
|
+
!value.includes('\0') &&
|
|
75
|
+
!value.includes('/') &&
|
|
76
|
+
!value.includes('\\') &&
|
|
77
|
+
value !== '.' &&
|
|
78
|
+
value !== '..';
|
|
79
|
+
|
|
80
|
+
const hasValidMainStepLog = (value: UnknownRecord): boolean => {
|
|
81
|
+
const log = value.log;
|
|
82
|
+
if (
|
|
83
|
+
log !== undefined &&
|
|
84
|
+
(!Array.isArray(log) ||
|
|
85
|
+
log.length > MAX_STEP_TRACE_LOG_EVENTS ||
|
|
86
|
+
!log.every(
|
|
87
|
+
(line) =>
|
|
88
|
+
typeof line === 'string' &&
|
|
89
|
+
line.length > 0 &&
|
|
90
|
+
line.length <= MAX_STEP_TRACE_LOG_EVENT_CHARS,
|
|
91
|
+
) ||
|
|
92
|
+
log.reduce<number>(
|
|
93
|
+
(total, line) => total + (typeof line === 'string' ? line.length : 0),
|
|
94
|
+
0,
|
|
95
|
+
) > MAX_STEP_TRACE_LOG_CHARS)
|
|
96
|
+
) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (value.logTruncated !== undefined && value.logTruncated !== true) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
value.omittedLogEvents !== undefined &&
|
|
104
|
+
(!Number.isSafeInteger(value.omittedLogEvents) ||
|
|
105
|
+
(value.omittedLogEvents as number) <= 0)
|
|
106
|
+
) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return (
|
|
110
|
+
(value.logTruncated === true) ===
|
|
111
|
+
(typeof value.omittedLogEvents === 'number') &&
|
|
112
|
+
!(
|
|
113
|
+
log === undefined &&
|
|
114
|
+
(value.logTruncated !== undefined || value.omittedLogEvents !== undefined)
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const isStepExecutionAttempt = (
|
|
120
|
+
value: unknown,
|
|
121
|
+
): value is StepExecutionAttempt => {
|
|
122
|
+
if (
|
|
123
|
+
!isRecord(value) ||
|
|
124
|
+
(value.kind !== 'main' && value.kind !== 'subagent') ||
|
|
125
|
+
typeof value.requestId !== 'string' ||
|
|
126
|
+
value.requestId.length === 0 ||
|
|
127
|
+
value.requestId.includes('\0') ||
|
|
128
|
+
(value.ordinal !== undefined &&
|
|
129
|
+
(!Number.isSafeInteger(value.ordinal) ||
|
|
130
|
+
(value.ordinal as number) <= 0)) ||
|
|
131
|
+
typeof value.task !== 'string' ||
|
|
132
|
+
value.task.trim().length === 0 ||
|
|
133
|
+
value.task.length > MAX_STEP_TRACE_TASK_CHARS ||
|
|
134
|
+
(value.taskTruncated !== undefined && value.taskTruncated !== true) ||
|
|
135
|
+
(value.omittedTaskChars !== undefined &&
|
|
136
|
+
(!Number.isSafeInteger(value.omittedTaskChars) ||
|
|
137
|
+
(value.omittedTaskChars as number) <= 0)) ||
|
|
138
|
+
(value.taskTruncated === true) !==
|
|
139
|
+
(typeof value.omittedTaskChars === 'number') ||
|
|
140
|
+
typeof value.startedAt !== 'number' ||
|
|
141
|
+
(value.result !== undefined && !isStepAttemptResult(value.result)) ||
|
|
142
|
+
(value.gateDecision !== undefined &&
|
|
143
|
+
!isStepGateDecision(value.gateDecision))
|
|
144
|
+
) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (value.kind === 'main') {
|
|
148
|
+
return (
|
|
149
|
+
value.agent === undefined &&
|
|
150
|
+
value.transcript === undefined &&
|
|
151
|
+
hasValidMainStepLog(value)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
value.log !== undefined ||
|
|
156
|
+
value.logTruncated !== undefined ||
|
|
157
|
+
value.omittedLogEvents !== undefined
|
|
158
|
+
) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
if (typeof value.agent !== 'string' || value.agent.length === 0) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
if (value.transcript === undefined) return true;
|
|
165
|
+
if (!isRecord(value.transcript)) return false;
|
|
166
|
+
const transcript = value.transcript;
|
|
167
|
+
if (
|
|
168
|
+
!isAbsoluteCwd(transcript.trustedRoot) ||
|
|
169
|
+
!isAbsoluteCwd(transcript.sessionFile) ||
|
|
170
|
+
!isSafeTraceIdentityField(transcript.runId) ||
|
|
171
|
+
!Number.isSafeInteger(transcript.childIndex) ||
|
|
172
|
+
(transcript.childIndex as number) < 0
|
|
173
|
+
) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return (
|
|
177
|
+
resolve(
|
|
178
|
+
transcript.trustedRoot,
|
|
179
|
+
transcript.runId,
|
|
180
|
+
`run-${String(transcript.childIndex)}`,
|
|
181
|
+
'session.jsonl',
|
|
182
|
+
) === resolve(transcript.sessionFile)
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const isStepExecutionAttempts = (
|
|
187
|
+
value: unknown,
|
|
188
|
+
): value is ReadonlyArray<StepExecutionAttempt> =>
|
|
189
|
+
Array.isArray(value) &&
|
|
190
|
+
value.length <= MAX_STEP_TRACE_ATTEMPTS &&
|
|
191
|
+
value.every(isStepExecutionAttempt) &&
|
|
192
|
+
new Set(value.map((attempt) => attempt.requestId)).size === value.length &&
|
|
193
|
+
value.every((attempt, index) => {
|
|
194
|
+
if (attempt.ordinal === undefined) return true;
|
|
195
|
+
const ordinal = attempt.ordinal;
|
|
196
|
+
return value
|
|
197
|
+
.slice(0, index)
|
|
198
|
+
.every(
|
|
199
|
+
(earlier) => earlier.ordinal === undefined || earlier.ordinal < ordinal,
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
15
203
|
const isStepHistoryEntry = (value: unknown): value is StepHistoryEntry =>
|
|
16
204
|
isRecord(value) &&
|
|
17
205
|
typeof value.stepId === 'string' &&
|
|
18
206
|
typeof value.stepDigest === 'string' &&
|
|
19
207
|
typeof value.outcome === 'string' &&
|
|
20
208
|
typeof value.summary === 'string' &&
|
|
209
|
+
(value.workspaceCwd === undefined || isAbsoluteCwd(value.workspaceCwd)) &&
|
|
210
|
+
(value.artifact === undefined || typeof value.artifact === 'string') &&
|
|
211
|
+
(value.approval === undefined ||
|
|
212
|
+
(isGateApproval(value.approval) &&
|
|
213
|
+
value.artifact === value.approval.artifact)) &&
|
|
214
|
+
(value.attempts === undefined || isStepExecutionAttempts(value.attempts)) &&
|
|
215
|
+
(value.omittedAttempts === undefined ||
|
|
216
|
+
(Number.isSafeInteger(value.omittedAttempts) &&
|
|
217
|
+
(value.omittedAttempts as number) > 0)) &&
|
|
21
218
|
typeof value.completedAt === 'number';
|
|
22
219
|
|
|
23
220
|
const isGateResolution = (value: unknown): value is GateResolution =>
|
|
@@ -49,6 +246,10 @@ const isVisitCounts = (value: unknown): value is Record<string, number> =>
|
|
|
49
246
|
const isOptionalString = (value: unknown): value is string | undefined =>
|
|
50
247
|
value === undefined || typeof value === 'string';
|
|
51
248
|
|
|
249
|
+
const isOptionalResumeInput = (value: unknown): value is string | undefined =>
|
|
250
|
+
value === undefined ||
|
|
251
|
+
(typeof value === 'string' && value.length <= MAX_RESUME_INPUT_CHARS);
|
|
252
|
+
|
|
52
253
|
const isWorkflowRunStatus = (value: unknown): value is WorkflowRunStatus =>
|
|
53
254
|
value === 'running' ||
|
|
54
255
|
value === 'paused' ||
|
|
@@ -75,6 +276,29 @@ const hasValidGateState = (
|
|
|
75
276
|
(run.status === 'awaiting-gate' ||
|
|
76
277
|
(run.status === 'paused' && run.pausedFrom === 'awaiting-gate'));
|
|
77
278
|
|
|
279
|
+
const hasValidWorkspaceState = (
|
|
280
|
+
run: UnknownRecord,
|
|
281
|
+
history: ReadonlyArray<StepHistoryEntry>,
|
|
282
|
+
): boolean => {
|
|
283
|
+
const startCwd = run.startCwd;
|
|
284
|
+
const cwd = run.cwd;
|
|
285
|
+
if (startCwd !== undefined && !isAbsoluteCwd(startCwd)) return false;
|
|
286
|
+
if (cwd !== undefined && !isAbsoluteCwd(cwd)) return false;
|
|
287
|
+
|
|
288
|
+
const bindings = history.flatMap((entry) =>
|
|
289
|
+
entry.workspaceCwd ? [entry.workspaceCwd] : [],
|
|
290
|
+
);
|
|
291
|
+
if (new Set(bindings).size > 1) return false;
|
|
292
|
+
const boundCwd = bindings.at(-1);
|
|
293
|
+
if (boundCwd !== undefined) {
|
|
294
|
+
return isAbsoluteCwd(startCwd) && isAbsoluteCwd(cwd) && cwd === boundCwd;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Legacy checkpoints have only `cwd`; new checkpoints keep start/current
|
|
298
|
+
// equal until a structured workspace binding is accepted.
|
|
299
|
+
return startCwd === undefined || cwd === startCwd;
|
|
300
|
+
};
|
|
301
|
+
|
|
78
302
|
/**
|
|
79
303
|
* Validates an unknown value as a persisted workflow run.
|
|
80
304
|
*
|
|
@@ -100,6 +324,11 @@ export const isWorkflowRun = (value: unknown): value is WorkflowRun => {
|
|
|
100
324
|
value.baselineTools.every((tool) => typeof tool === 'string') &&
|
|
101
325
|
Array.isArray(value.history) &&
|
|
102
326
|
value.history.every(isStepHistoryEntry) &&
|
|
327
|
+
(value.currentStepAttempts === undefined ||
|
|
328
|
+
isStepExecutionAttempts(value.currentStepAttempts)) &&
|
|
329
|
+
(value.currentStepOmittedAttempts === undefined ||
|
|
330
|
+
(Number.isSafeInteger(value.currentStepOmittedAttempts) &&
|
|
331
|
+
(value.currentStepOmittedAttempts as number) > 0)) &&
|
|
103
332
|
isVisitCounts(value.visits) &&
|
|
104
333
|
typeof value.startedAt === 'number' &&
|
|
105
334
|
typeof value.updatedAt === 'number' &&
|
|
@@ -111,6 +340,7 @@ export const isWorkflowRun = (value: unknown): value is WorkflowRun => {
|
|
|
111
340
|
isOptionalString(value.reviewedArtifact) &&
|
|
112
341
|
isOptionalString(value.reviewedFeedback) &&
|
|
113
342
|
isOptionalString(value.stepHandoff) &&
|
|
343
|
+
isOptionalResumeInput(value.resumeInput) &&
|
|
114
344
|
isOptionalString(value.pauseReason) &&
|
|
115
345
|
isOptionalString(value.failedStepId) &&
|
|
116
346
|
(value.pausedFrom === undefined ||
|
|
@@ -122,6 +352,11 @@ export const isWorkflowRun = (value: unknown): value is WorkflowRun => {
|
|
|
122
352
|
if (pendingGate !== undefined && !isPendingGate(pendingGate)) return false;
|
|
123
353
|
|
|
124
354
|
return (
|
|
355
|
+
workflowTraceChars(value as WorkflowRun) <= MAX_WORKFLOW_TRACE_CHARS &&
|
|
356
|
+
hasValidWorkspaceState(
|
|
357
|
+
value,
|
|
358
|
+
value.history as ReadonlyArray<StepHistoryEntry>,
|
|
359
|
+
) &&
|
|
125
360
|
hasValidPauseState(value) &&
|
|
126
361
|
hasValidFailureState(value) &&
|
|
127
362
|
hasValidGateState(value, pendingGate)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { LoadedWorkflow, WorkflowStep } from '../config/types.ts';
|
|
2
|
+
import type { StepHistoryEntry, WorkflowRun } from './state-types.ts';
|
|
3
|
+
|
|
4
|
+
const sameVisitCounts = (
|
|
5
|
+
actual: Readonly<Record<string, number>>,
|
|
6
|
+
expected: Readonly<Record<string, number>>,
|
|
7
|
+
): boolean => {
|
|
8
|
+
const actualEntries = Object.entries(actual).sort(([left], [right]) =>
|
|
9
|
+
left.localeCompare(right),
|
|
10
|
+
);
|
|
11
|
+
const expectedEntries = Object.entries(expected).sort(([left], [right]) =>
|
|
12
|
+
left.localeCompare(right),
|
|
13
|
+
);
|
|
14
|
+
return (
|
|
15
|
+
actualEntries.length === expectedEntries.length &&
|
|
16
|
+
actualEntries.every(
|
|
17
|
+
([stepId, count], index) =>
|
|
18
|
+
expectedEntries[index]?.[0] === stepId &&
|
|
19
|
+
expectedEntries[index][1] === count,
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const validateHistoryMetadata = (
|
|
25
|
+
workflow: LoadedWorkflow,
|
|
26
|
+
step: WorkflowStep,
|
|
27
|
+
entry: StepHistoryEntry,
|
|
28
|
+
): string | undefined => {
|
|
29
|
+
const shouldBind = step.workspace?.bindOn.includes(entry.outcome) === true;
|
|
30
|
+
if (shouldBind !== (entry.workspaceCwd !== undefined)) {
|
|
31
|
+
return shouldBind
|
|
32
|
+
? `history step "${entry.stepId}" is missing its workspace binding`
|
|
33
|
+
: `history step "${entry.stepId}" has an unauthorized workspace binding`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isApprovedGate =
|
|
37
|
+
step.gate !== undefined && entry.outcome === step.gate.approvedOutcome;
|
|
38
|
+
if (isApprovedGate && !entry.approval) {
|
|
39
|
+
return `history step "${entry.stepId}" is missing authoritative gate approval`;
|
|
40
|
+
}
|
|
41
|
+
if (isApprovedGate && entry.artifact !== entry.approval?.artifact) {
|
|
42
|
+
return `history step "${entry.stepId}" approval artifact is inconsistent`;
|
|
43
|
+
}
|
|
44
|
+
if (!isApprovedGate && (entry.approval || entry.artifact !== undefined)) {
|
|
45
|
+
return `history step "${entry.stepId}" has approval data for a non-approved outcome`;
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
isApprovedGate &&
|
|
49
|
+
entry.approval?.stepStructuralDigest !==
|
|
50
|
+
workflow.stepStructuralDigests[entry.stepId]
|
|
51
|
+
) {
|
|
52
|
+
return `history step "${entry.stepId}" approval does not match its configured structure`;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const validatePendingGate = (
|
|
58
|
+
run: WorkflowRun,
|
|
59
|
+
step: WorkflowStep,
|
|
60
|
+
): string | undefined => {
|
|
61
|
+
const pending = run.pendingGate;
|
|
62
|
+
if (!pending) return undefined;
|
|
63
|
+
if (
|
|
64
|
+
!step.gate ||
|
|
65
|
+
pending.stepId !== run.currentStepId ||
|
|
66
|
+
pending.provider !== step.gate.provider ||
|
|
67
|
+
pending.submittedOutcome !== step.gate.submitOutcome
|
|
68
|
+
) {
|
|
69
|
+
return 'pending gate does not match the current workflow step';
|
|
70
|
+
}
|
|
71
|
+
if (pending.provider === 'prompt' && pending.reviewId !== undefined) {
|
|
72
|
+
return 'built-in prompt gate cannot carry a Plannotator review id';
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validates persisted control-flow state against the loaded declarative graph.
|
|
79
|
+
*
|
|
80
|
+
* When configuration changed, the first digest-mismatched history entry is
|
|
81
|
+
* intentionally left to reconciliation, which restarts that exact reachable
|
|
82
|
+
* step and discards everything after it.
|
|
83
|
+
*/
|
|
84
|
+
export function validateRunWorkflowSemantics(
|
|
85
|
+
run: WorkflowRun,
|
|
86
|
+
workflow: LoadedWorkflow,
|
|
87
|
+
): string | undefined {
|
|
88
|
+
const sameWorkflowDigest = run.workflowDigest === workflow.digest;
|
|
89
|
+
let expectedStepId = workflow.definition.start;
|
|
90
|
+
let reachedDone = false;
|
|
91
|
+
let boundWorkspaceCwd: string | undefined;
|
|
92
|
+
let latestApproval:
|
|
93
|
+
{ readonly artifact: string; readonly feedback: string } | undefined;
|
|
94
|
+
const expectedVisits: Record<string, number> = { [expectedStepId]: 1 };
|
|
95
|
+
|
|
96
|
+
for (let index = 0; index < run.history.length; index += 1) {
|
|
97
|
+
const entry = run.history[index];
|
|
98
|
+
if (!entry) continue;
|
|
99
|
+
if (reachedDone) {
|
|
100
|
+
return 'workflow history continues after a $done transition';
|
|
101
|
+
}
|
|
102
|
+
if (entry.stepId !== expectedStepId) {
|
|
103
|
+
return `history step "${entry.stepId}" is not reachable after "${expectedStepId}"`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const step = workflow.definition.steps[entry.stepId];
|
|
107
|
+
const configuredDigest = workflow.stepDigests[entry.stepId];
|
|
108
|
+
if (!step || entry.stepDigest !== configuredDigest) {
|
|
109
|
+
return sameWorkflowDigest
|
|
110
|
+
? `history step "${entry.stepId}" does not match the active workflow digest`
|
|
111
|
+
: undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const metadataError = validateHistoryMetadata(workflow, step, entry);
|
|
115
|
+
if (metadataError) return metadataError;
|
|
116
|
+
if (entry.workspaceCwd) {
|
|
117
|
+
if (
|
|
118
|
+
boundWorkspaceCwd !== undefined &&
|
|
119
|
+
boundWorkspaceCwd !== entry.workspaceCwd
|
|
120
|
+
) {
|
|
121
|
+
return 'workflow history attempts to replace an existing workspace binding';
|
|
122
|
+
}
|
|
123
|
+
boundWorkspaceCwd = entry.workspaceCwd;
|
|
124
|
+
}
|
|
125
|
+
if (entry.approval) {
|
|
126
|
+
latestApproval = {
|
|
127
|
+
artifact: entry.approval.artifact,
|
|
128
|
+
feedback: entry.approval.feedback,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const target = step.transitions[entry.outcome];
|
|
133
|
+
if (!target) {
|
|
134
|
+
return `history outcome "${entry.outcome}" is not configured for step "${entry.stepId}"`;
|
|
135
|
+
}
|
|
136
|
+
if (target === '$pause') {
|
|
137
|
+
return `history step "${entry.stepId}" records a non-completing $pause transition`;
|
|
138
|
+
}
|
|
139
|
+
if (target === '$done') {
|
|
140
|
+
if (index !== run.history.length - 1) {
|
|
141
|
+
return 'workflow history continues after a $done transition';
|
|
142
|
+
}
|
|
143
|
+
reachedDone = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
expectedStepId = target;
|
|
148
|
+
expectedVisits[target] = (expectedVisits[target] ?? 0) + 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (run.currentStepId !== expectedStepId) {
|
|
152
|
+
return `current step "${run.currentStepId}" does not match reachable step "${expectedStepId}"`;
|
|
153
|
+
}
|
|
154
|
+
if (reachedDone !== (run.status === 'completed')) {
|
|
155
|
+
return reachedDone
|
|
156
|
+
? 'a workflow that reached $done must be completed'
|
|
157
|
+
: 'a completed workflow has no $done transition in its history';
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
run.status === 'completed' &&
|
|
161
|
+
run.currentStepDigest !== run.history.at(-1)?.stepDigest
|
|
162
|
+
) {
|
|
163
|
+
return 'completed workflow current-step digest does not match its terminal history';
|
|
164
|
+
}
|
|
165
|
+
if (!sameVisitCounts(run.visits, expectedVisits)) {
|
|
166
|
+
return 'workflow visit counts do not match its execution history';
|
|
167
|
+
}
|
|
168
|
+
const reviewedArtifact = run.reviewedArtifact ?? '';
|
|
169
|
+
const reviewedFeedback = run.reviewedFeedback ?? '';
|
|
170
|
+
if (
|
|
171
|
+
reviewedArtifact !== (latestApproval?.artifact ?? '') ||
|
|
172
|
+
reviewedFeedback !== (latestApproval?.feedback ?? '')
|
|
173
|
+
) {
|
|
174
|
+
return 'reviewed artifact and feedback do not match authoritative approval history';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const currentStep = workflow.definition.steps[run.currentStepId];
|
|
178
|
+
if (!currentStep) {
|
|
179
|
+
return `current step "${run.currentStepId}" is missing from the workflow`;
|
|
180
|
+
}
|
|
181
|
+
const currentStepChanged =
|
|
182
|
+
run.currentStepDigest !== workflow.stepDigests[run.currentStepId];
|
|
183
|
+
if (sameWorkflowDigest && currentStepChanged) {
|
|
184
|
+
return `current step "${run.currentStepId}" does not match the active workflow digest`;
|
|
185
|
+
}
|
|
186
|
+
if (boundWorkspaceCwd && !currentStep.subagent) {
|
|
187
|
+
return `bound workflow current step "${run.currentStepId}" must use a subagent`;
|
|
188
|
+
}
|
|
189
|
+
if (currentStepChanged) return undefined;
|
|
190
|
+
return validatePendingGate(run, currentStep);
|
|
191
|
+
}
|