@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.
Files changed (131) hide show
  1. package/README.md +312 -223
  2. package/agents/step.md +4 -11
  3. package/dist/index.js +2554 -976
  4. package/examples/mr-comments.workflow.yaml +16 -8
  5. package/examples/prompts/mr-comments/implement.md +13 -9
  6. package/examples/prompts/mr-comments/plan.md +44 -15
  7. package/examples/prompts/mr-comments/verify.md +8 -7
  8. package/examples/settings.yaml +7 -1
  9. package/examples/starter-kit/mr-comment.workflow.yaml +149 -0
  10. package/examples/starter-kit/mr-review.workflow.yaml +130 -0
  11. package/examples/starter-kit/steps/mr-comment/fetch.md +33 -0
  12. package/examples/starter-kit/steps/mr-comment/implement.md +38 -0
  13. package/examples/starter-kit/steps/mr-comment/plan.md +62 -0
  14. package/examples/starter-kit/steps/mr-comment/publish.md +36 -0
  15. package/examples/starter-kit/steps/mr-comment/verify.md +35 -0
  16. package/examples/starter-kit/steps/mr-review/fetch.md +25 -0
  17. package/examples/starter-kit/steps/mr-review/publish.md +30 -0
  18. package/examples/starter-kit/steps/mr-review/review.md +69 -0
  19. package/examples/starter-kit/steps/mr-review/verify.md +24 -0
  20. package/examples/starter-kit/steps/shared/prepare-workspace.md +57 -0
  21. package/examples/starter-kit/steps/ticket/implement.md +35 -0
  22. package/examples/starter-kit/steps/ticket/plan.md +40 -0
  23. package/examples/starter-kit/steps/ticket/verify.md +29 -0
  24. package/examples/starter-kit/steps/work/implement.md +34 -0
  25. package/examples/starter-kit/steps/work/plan.md +38 -0
  26. package/examples/starter-kit/steps/work/verify.md +30 -0
  27. package/examples/starter-kit/ticket.workflow.yaml +100 -0
  28. package/examples/starter-kit/work.workflow.yaml +97 -0
  29. package/package.json +3 -3
  30. package/schemas/workflow.schema.json +78 -44
  31. package/src/command-names.ts +1 -0
  32. package/src/commands.ts +33 -4
  33. package/src/config/ceiling.ts +6 -12
  34. package/src/config/load-workflows.ts +12 -1
  35. package/src/config/step-digests.ts +19 -0
  36. package/src/config/types.ts +17 -22
  37. package/src/config/validation/permissions.ts +5 -66
  38. package/src/config/validation/prompt.ts +1 -0
  39. package/src/config/validation/shared.ts +0 -2
  40. package/src/config/validation/step.ts +106 -2
  41. package/src/config/validation/workflow.ts +76 -0
  42. package/src/engine/create-run.ts +3 -0
  43. package/src/engine/gate-transitions.ts +58 -9
  44. package/src/engine/reconciliation-history.ts +61 -24
  45. package/src/engine/run-advance.ts +37 -4
  46. package/src/engine/run-lifecycle.ts +11 -0
  47. package/src/engine/run-reconciliation.ts +51 -21
  48. package/src/engine/run-validation.ts +235 -0
  49. package/src/engine/run-workflow-validation.ts +191 -0
  50. package/src/engine/state-types.ts +108 -5
  51. package/src/engine/state.ts +15 -1
  52. package/src/engine/step-trace.ts +495 -0
  53. package/src/engine/transitions.ts +1 -0
  54. package/src/harness/action-context.ts +31 -6
  55. package/src/harness/core-actions.ts +30 -1
  56. package/src/harness/delegation-control-actions.ts +27 -2
  57. package/src/harness/delegation-failure.ts +42 -9
  58. package/src/harness/delegation-plan.ts +96 -40
  59. package/src/harness/delegation-recovery-validation.ts +3 -1
  60. package/src/harness/delegation-response-actions.ts +73 -3
  61. package/src/harness/delegation-retry-policy.ts +5 -3
  62. package/src/harness/dependencies.ts +11 -2
  63. package/src/harness/gate-submission-action.ts +5 -31
  64. package/src/harness/lifecycle-actions.ts +4 -5
  65. package/src/harness/pause-actions.ts +10 -1
  66. package/src/harness/plannotator-result-actions.ts +20 -11
  67. package/src/harness/prompt-gate-actions.ts +40 -2
  68. package/src/harness/resume-action.ts +75 -1
  69. package/src/harness/start-actions.ts +77 -3
  70. package/src/harness/status-actions.ts +7 -17
  71. package/src/harness/step-effects.ts +61 -0
  72. package/src/harness/step-execution-actions.ts +181 -25
  73. package/src/harness/step-reporting.ts +168 -0
  74. package/src/harness/types.ts +1 -0
  75. package/src/harness/workspace-directory.ts +67 -0
  76. package/src/harness.ts +37 -9
  77. package/src/integrations/plannotator-requests.ts +40 -5
  78. package/src/integrations/subagents/child-policy-sections.ts +71 -84
  79. package/src/integrations/subagents/child-policy-types.ts +7 -7
  80. package/src/integrations/subagents/child-policy-validation.ts +10 -4
  81. package/src/integrations/subagents/child-runtime-completion.ts +1 -0
  82. package/src/integrations/subagents/child-runtime-files.ts +29 -86
  83. package/src/integrations/subagents/child-runtime-policy.ts +8 -33
  84. package/src/integrations/subagents/child-runtime.ts +3 -15
  85. package/src/integrations/subagents/delegated-result.ts +1 -0
  86. package/src/integrations/subagents/diagnostic-format.ts +5 -0
  87. package/src/integrations/subagents/diagnostic-types.ts +7 -1
  88. package/src/integrations/subagents/diagnostics.ts +2 -0
  89. package/src/integrations/subagents/failure-correlation.ts +52 -2
  90. package/src/integrations/subagents/failure-transcript.ts +60 -1
  91. package/src/integrations/subagents/replay-audit.ts +0 -1
  92. package/src/integrations/subagents/replay-safety.ts +12 -27
  93. package/src/integrations/subagents/session-diagnostics.ts +102 -3
  94. package/src/policy/bash-authorization.ts +2 -21
  95. package/src/policy/bash-types.ts +0 -5
  96. package/src/policy/bash.ts +1 -2
  97. package/src/policy/tool-call-authorization.ts +1 -7
  98. package/src/preflight.ts +0 -5
  99. package/src/prompt/retry-task.ts +11 -3
  100. package/src/prompt/step-contract.ts +10 -56
  101. package/src/prompt/step-sections.ts +8 -10
  102. package/src/prompt/step-task.ts +41 -10
  103. package/src/prompt/template.ts +3 -2
  104. package/src/runtime/completion-tool.ts +14 -0
  105. package/src/runtime/main-step-completion.ts +3 -0
  106. package/src/runtime/main-step-policy.ts +2 -1
  107. package/src/runtime/main-step-runtime-types.ts +9 -1
  108. package/src/runtime/main-step-runtime.ts +2 -0
  109. package/src/runtime/main-step-state.ts +6 -0
  110. package/src/runtime/main-step-trace.ts +45 -0
  111. package/src/runtime/step-result.ts +57 -0
  112. package/src/step-log.ts +178 -0
  113. package/src/workflow-doctor.ts +254 -0
  114. package/src/workflow-status/format-status.ts +32 -3
  115. package/src/workflow-status/render-board.ts +7 -4
  116. package/src/workflow-status/render-path.ts +51 -24
  117. package/src/workflow-status/render-step-detail.ts +379 -0
  118. package/src/workflow-status/render-summary.ts +22 -0
  119. package/src/workflow-status/transcript-reader.ts +231 -0
  120. package/src/workflow-status/types.ts +8 -0
  121. package/src/workflow-status/view.ts +161 -14
  122. package/src/workflow-status.ts +6 -0
  123. package/src/policy/approved-command-extraction.ts +0 -71
  124. package/src/policy/approved-commands.ts +0 -15
  125. package/src/policy/bash-read-only.ts +0 -161
  126. package/src/policy/restricted-git.ts +0 -30
  127. package/src/policy/reviewed-artifact.ts +0 -97
  128. package/src/policy/reviewed-command-safety.ts +0 -150
  129. package/src/policy/reviewed-command-shape.ts +0 -49
  130. package/src/policy/reviewed-repository-contract.ts +0 -138
  131. 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
- retainedReviewedArtifact,
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
- if (run.workflowDigest === workflow.digest) {
34
- return { run, changed: false };
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 (!workflow.definition.steps[run.currentStepId]) {
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
- const reconciledRun = refreshApprovedGateHistory(run, workflow);
44
- const changedHistoryIndex = reconciledRun.history.findIndex(
45
- (entry) => workflow.stepDigests[entry.stepId] !== entry.stepDigest,
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 = reconciledRun.history[changedHistoryIndex];
49
- if (!changedEntry || !workflow.definition.steps[changedEntry.stepId]) {
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
- reviewedArtifact: retainedReviewedArtifact(
73
- workflow,
74
- reconciledRun,
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
+ }