@wichayutdew/pi-workflows 1.0.0 → 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 -221
  2. package/agents/step.md +4 -11
  3. package/dist/index.js +2556 -961
  4. package/examples/mr-comments.workflow.yaml +18 -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 -35
  31. package/src/command-names.ts +1 -0
  32. package/src/commands.ts +33 -4
  33. package/src/config/ceiling.ts +6 -8
  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 -11
  37. package/src/config/validation/permissions.ts +5 -45
  38. package/src/config/validation/prompt.ts +3 -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 +4 -0
  43. package/src/engine/gate-transitions.ts +58 -6
  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 +236 -0
  49. package/src/engine/run-workflow-validation.ts +191 -0
  50. package/src/engine/state-types.ts +109 -4
  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 -72
  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 +5 -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
@@ -67,6 +67,82 @@ function validateGraph(
67
67
  }
68
68
  });
69
69
  });
70
+ validateWorkspaceGraph(steps, errors);
71
+ }
72
+
73
+ function validateWorkspaceGraph(
74
+ steps: Readonly<Record<string, WorkflowStep>>,
75
+ errors: ValidationErrors,
76
+ ): void {
77
+ const binders = Object.entries(steps).filter(
78
+ (
79
+ entry,
80
+ ): entry is [
81
+ string,
82
+ WorkflowStep & { workspace: NonNullable<WorkflowStep['workspace']> },
83
+ ] => entry[1].workspace !== undefined,
84
+ );
85
+ const firstBinderId = binders[0]?.[0];
86
+
87
+ binders.forEach(([stepId, step], index) => {
88
+ const workspacePath = `workflow.steps.${stepId}.workspace`;
89
+ if (index > 0) {
90
+ errors.push(
91
+ `${workspacePath}: only one workspace-binding step is allowed; "${firstBinderId}" also configures workspace binding`,
92
+ );
93
+ }
94
+ if (!step.subagent) {
95
+ errors.push(`${workspacePath}: workspace binding requires a subagent`);
96
+ }
97
+ if (step.gate) {
98
+ errors.push(
99
+ `${workspacePath}: workspace binding is not allowed on a gated step`,
100
+ );
101
+ }
102
+
103
+ const downstream = step.workspace.bindOn.flatMap((outcome) => {
104
+ const target = step.transitions[outcome];
105
+ if (target === '$done' || target === '$pause') {
106
+ errors.push(
107
+ `workflow.steps.${stepId}.transitions.${outcome}: workspace-binding outcome must target an ordinary step`,
108
+ );
109
+ return [];
110
+ }
111
+ return target && Object.hasOwn(steps, target) ? [target] : [];
112
+ });
113
+ validateWorkspaceDescendants(steps, downstream, errors);
114
+ });
115
+ }
116
+
117
+ function validateWorkspaceDescendants(
118
+ steps: Readonly<Record<string, WorkflowStep>>,
119
+ initialStepIds: ReadonlyArray<string>,
120
+ errors: ValidationErrors,
121
+ ): void {
122
+ const pending = [...initialStepIds];
123
+ const visited = new Set<string>();
124
+ while (pending.length > 0) {
125
+ const stepId = pending.pop();
126
+ if (!stepId || visited.has(stepId)) continue;
127
+ visited.add(stepId);
128
+ const step = steps[stepId];
129
+ if (!step) continue;
130
+ if (!step.subagent) {
131
+ errors.push(
132
+ `workflow.steps.${stepId}.subagent: every nonterminal step reachable after workspace binding must use a subagent`,
133
+ );
134
+ }
135
+ Object.values(step.transitions).forEach((target) => {
136
+ if (
137
+ target !== '$done' &&
138
+ target !== '$pause' &&
139
+ Object.hasOwn(steps, target) &&
140
+ !visited.has(target)
141
+ ) {
142
+ pending.push(target);
143
+ }
144
+ });
145
+ }
70
146
  }
71
147
 
72
148
  /** Validate and normalize an untrusted workflow definition. */
@@ -10,6 +10,7 @@ import type { WorkflowRun } from './state-types.ts';
10
10
  * @param baselineTools - Tool names available before workflow restrictions.
11
11
  * @param runId - Stable run identifier.
12
12
  * @param now - Creation timestamp.
13
+ * @param cwd - Canonical working directory captured at workflow start.
13
14
  * @returns A new running workflow state.
14
15
  */
15
16
  export const createRun = (
@@ -18,6 +19,7 @@ export const createRun = (
18
19
  baselineTools: ReadonlyArray<string>,
19
20
  runId: string,
20
21
  now: number,
22
+ cwd?: string,
21
23
  ): WorkflowRun => {
22
24
  const startStepId = workflow.definition.start;
23
25
  return {
@@ -35,6 +37,8 @@ export const createRun = (
35
37
  startedAt: now,
36
38
  updatedAt: now,
37
39
  reviewedArtifact: '',
40
+ reviewedFeedback: '',
41
+ ...(cwd ? { startCwd: cwd, cwd } : {}),
38
42
  stepHandoff: '',
39
43
  lastSummary: '',
40
44
  gateFeedback: '',
@@ -1,5 +1,6 @@
1
1
  import type { LoadedWorkflow } from '../config/types.ts';
2
2
  import { advanceRun } from './run-advance.ts';
3
+ import { recordCurrentGateDecision } from './step-trace.ts';
3
4
  import type { GateResolution, WorkflowRun } from './state-types.ts';
4
5
  import { currentStep, withRunUpdate } from './transition-helpers.ts';
5
6
 
@@ -12,6 +13,7 @@ import { currentStep, withRunUpdate } from './transition-helpers.ts';
12
13
  * @param artifact - Artifact to display for review.
13
14
  * @param requestId - Stable gate request identifier.
14
15
  * @param now - Update timestamp.
16
+ * @param summary - Compact step handoff, separate from the review artifact.
15
17
  * @returns A new awaiting-gate workflow state.
16
18
  * @throws When the run or gate submission is invalid.
17
19
  */
@@ -22,6 +24,7 @@ export const beginGate = (
22
24
  artifact: string,
23
25
  requestId: string,
24
26
  now: number,
27
+ summary: string,
25
28
  ): WorkflowRun => {
26
29
  if (run.status !== 'running') {
27
30
  throw new Error(
@@ -38,6 +41,9 @@ export const beginGate = (
38
41
  if (!artifact.trim()) {
39
42
  throw new Error('gate submission requires a non-empty artifact');
40
43
  }
44
+ if (!summary.trim()) {
45
+ throw new Error('gate submission requires a non-empty summary');
46
+ }
41
47
  if (!requestId) throw new Error('gate submission requires a request id');
42
48
 
43
49
  return withRunUpdate(
@@ -49,6 +55,7 @@ export const beginGate = (
49
55
  requestId,
50
56
  stepId: run.currentStepId,
51
57
  artifact,
58
+ summary,
52
59
  submittedOutcome: outcome,
53
60
  requestedAt: now,
54
61
  },
@@ -102,6 +109,9 @@ export const failGate = (
102
109
  status: 'running',
103
110
  pendingGate: undefined,
104
111
  gateFeedback: reason,
112
+ pausedFrom: undefined,
113
+ pauseReason: undefined,
114
+ failedStepId: undefined,
105
115
  },
106
116
  now,
107
117
  )
@@ -158,27 +168,69 @@ export const resolveGate = (
158
168
  const outcome = resolution.approved
159
169
  ? step.gate.approvedOutcome
160
170
  : step.gate.rejectedOutcome;
171
+ const stepStructuralDigest =
172
+ workflow.stepStructuralDigests[pendingGate.stepId] ?? '';
173
+ if (resolution.approved && !stepStructuralDigest) {
174
+ throw new Error(
175
+ `gated step "${pendingGate.stepId}" has no structural digest`,
176
+ );
177
+ }
161
178
  const summary = resolution.approved
162
- ? pendingGate.artifact
179
+ ? (pendingGate.summary ?? '')
163
180
  : resolution.feedback
164
181
  ? `Gate rejected: ${resolution.feedback}`
165
182
  : 'Gate rejected';
166
- const runnableRun = withRunUpdate(
183
+ const decidedRun = recordCurrentGateDecision(
167
184
  run,
185
+ {
186
+ provider: pendingGate.provider,
187
+ requestId: pendingGate.requestId,
188
+ approved: resolution.approved,
189
+ feedback: resolution.feedback,
190
+ resolvedAt: resolution.resolvedAt,
191
+ ...(pendingGate.reviewId ? { reviewId: pendingGate.reviewId } : {}),
192
+ },
193
+ now,
194
+ );
195
+ const runnableRun = withRunUpdate(
196
+ decidedRun,
168
197
  {
169
198
  status: 'running',
170
199
  pendingGate: undefined,
171
- ...(resolution.approved
172
- ? { reviewedArtifact: pendingGate.artifact }
173
- : {}),
174
200
  pausedFrom: undefined,
175
201
  pauseReason: undefined,
176
202
  gateFeedback: resolution.feedback,
177
203
  },
178
204
  now,
179
205
  );
206
+ const advanced = advanceRun(workflow, runnableRun, outcome, summary, now);
207
+ const completedApprovedGate =
208
+ resolution.approved && advanced.history.length > runnableRun.history.length;
209
+ const history = completedApprovedGate
210
+ ? advanced.history.map((entry, index) =>
211
+ index === advanced.history.length - 1
212
+ ? {
213
+ ...entry,
214
+ artifact: pendingGate.artifact,
215
+ approval: {
216
+ requestId: pendingGate.requestId,
217
+ artifact: pendingGate.artifact,
218
+ feedback: resolution.feedback,
219
+ stepStructuralDigest,
220
+ },
221
+ }
222
+ : entry,
223
+ )
224
+ : advanced.history;
180
225
  return {
181
- ...advanceRun(workflow, runnableRun, outcome, summary, now),
226
+ ...advanced,
227
+ history,
228
+ ...(completedApprovedGate
229
+ ? {
230
+ reviewedArtifact: pendingGate.artifact,
231
+ reviewedFeedback: resolution.feedback,
232
+ }
233
+ : {}),
182
234
  gateFeedback: resolution.feedback,
183
235
  };
184
236
  };
@@ -1,6 +1,30 @@
1
1
  import type { LoadedWorkflow } from '../config/types.ts';
2
2
  import type { StepHistoryEntry, WorkflowRun } from './state-types.ts';
3
3
 
4
+ type ReviewedApproval = {
5
+ readonly artifact: string;
6
+ readonly feedback: string;
7
+ };
8
+
9
+ const isApprovedGateEntry = (
10
+ workflow: LoadedWorkflow,
11
+ entry: StepHistoryEntry,
12
+ ): boolean => {
13
+ const gate = workflow.definition.steps[entry.stepId]?.gate;
14
+ return gate !== undefined && entry.outcome === gate.approvedOutcome;
15
+ };
16
+
17
+ const latestApprovedGateEntry = (
18
+ workflow: LoadedWorkflow,
19
+ history: ReadonlyArray<StepHistoryEntry>,
20
+ ): { readonly entry: StepHistoryEntry; readonly index: number } | undefined => {
21
+ for (let index = history.length - 1; index >= 0; index -= 1) {
22
+ const entry = history[index];
23
+ if (entry && isApprovedGateEntry(workflow, entry)) return { entry, index };
24
+ }
25
+ return undefined;
26
+ };
27
+
4
28
  /**
5
29
  * Rebuilds step visit counts from retained history and the current step.
6
30
  *
@@ -26,37 +50,49 @@ export const rebuildVisits = (
26
50
  };
27
51
 
28
52
  /**
29
- * Retains reviewed authority only when its approving history remains.
53
+ * Restores the execution directory represented by retained history.
30
54
  *
31
- * @param workflow - Updated workflow.
32
- * @param run - Existing workflow state.
55
+ * @param run - Existing workflow state with immutable start directory.
33
56
  * @param history - History retained after reconciliation.
34
- * @returns The retained artifact, or an empty string when provenance was lost.
57
+ * @returns The latest retained binding, or the workflow start directory.
35
58
  */
36
- export const retainedReviewedArtifact = (
37
- workflow: LoadedWorkflow,
59
+ export const retainedWorkspaceCwd = (
38
60
  run: WorkflowRun,
39
61
  history: ReadonlyArray<StepHistoryEntry>,
40
- ): string => {
41
- const reviewedArtifact = run.reviewedArtifact ?? '';
42
- if (!reviewedArtifact) return '';
62
+ ): string | undefined => {
63
+ for (let index = history.length - 1; index >= 0; index -= 1) {
64
+ const cwd = history[index]?.workspaceCwd;
65
+ if (cwd) return cwd;
66
+ }
67
+ return run.startCwd ?? run.cwd;
68
+ };
43
69
 
44
- const isApprovalRetained = history.some((entry) => {
45
- const gate = workflow.definition.steps[entry.stepId]?.gate;
46
- return (
47
- gate !== undefined &&
48
- entry.outcome === gate.approvedOutcome &&
49
- entry.summary === reviewedArtifact
50
- );
51
- });
52
- return isApprovalRetained ? reviewedArtifact : '';
70
+ /**
71
+ * Restores one reviewed approval from the latest retained approving history.
72
+ *
73
+ * Approved history records carry their own artifact, feedback, gate request
74
+ * identity, and prompt-excluded structural digest.
75
+ *
76
+ * @param workflow - Updated workflow.
77
+ * @param history - History retained after reconciliation.
78
+ * @returns The retained pair, or `undefined` when provenance was lost.
79
+ */
80
+ export const retainedReviewedApproval = (
81
+ workflow: LoadedWorkflow,
82
+ history: ReadonlyArray<StepHistoryEntry>,
83
+ ): ReviewedApproval | undefined => {
84
+ const retainedApproval = latestApprovedGateEntry(workflow, history);
85
+ const approval = retainedApproval?.entry.approval;
86
+ return approval
87
+ ? { artifact: approval.artifact, feedback: approval.feedback }
88
+ : undefined;
53
89
  };
54
90
 
55
91
  /**
56
92
  * Refreshes the digest of retained approved-gate history.
57
93
  *
58
- * The reviewed artifact itself proves the gate output is unchanged, allowing a
59
- * prompt-only configuration refresh without discarding approved authority.
94
+ * The approved history's prompt-excluded structural digest must still match,
95
+ * allowing only a prompt-only refresh without discarding the reviewed value.
60
96
  *
61
97
  * @param run - Existing workflow state.
62
98
  * @param workflow - Updated workflow.
@@ -66,18 +102,19 @@ export const refreshApprovedGateHistory = (
66
102
  run: WorkflowRun,
67
103
  workflow: LoadedWorkflow,
68
104
  ): WorkflowRun => {
69
- const reviewedArtifact = run.reviewedArtifact ?? '';
70
- if (!reviewedArtifact) return run;
71
-
72
105
  const history = run.history.map((entry) => {
73
106
  const gate = workflow.definition.steps[entry.stepId]?.gate;
74
107
  const currentDigest = workflow.stepDigests[entry.stepId];
108
+ const currentStructuralDigest =
109
+ workflow.stepStructuralDigests[entry.stepId];
75
110
  const shouldRefresh =
76
111
  gate !== undefined &&
77
112
  typeof currentDigest === 'string' &&
78
113
  currentDigest.length > 0 &&
114
+ typeof currentStructuralDigest === 'string' &&
115
+ currentStructuralDigest.length > 0 &&
79
116
  entry.outcome === gate.approvedOutcome &&
80
- entry.summary === reviewedArtifact &&
117
+ entry.approval?.stepStructuralDigest === currentStructuralDigest &&
81
118
  entry.stepDigest !== currentDigest;
82
119
  return shouldRefresh ? { ...entry, stepDigest: currentDigest } : entry;
83
120
  });
@@ -2,16 +2,29 @@ import type { LoadedWorkflow } from '../config/types.ts';
2
2
  import type { StepHistoryEntry, WorkflowRun } from './state-types.ts';
3
3
  import { currentStep, withRunUpdate } from './transition-helpers.ts';
4
4
 
5
+ export type RunStepEffects = {
6
+ /** Canonical directory selected through the step's YAML-authorized result. */
7
+ readonly workspaceCwd?: string | undefined;
8
+ };
9
+
5
10
  const completedStep = (
6
11
  run: WorkflowRun,
7
12
  outcome: string,
8
13
  summary: string,
9
14
  now: number,
15
+ effects: RunStepEffects,
10
16
  ): StepHistoryEntry => ({
11
17
  stepId: run.currentStepId,
12
18
  stepDigest: run.currentStepDigest,
13
19
  outcome,
14
20
  summary,
21
+ ...(effects.workspaceCwd ? { workspaceCwd: effects.workspaceCwd } : {}),
22
+ ...(run.currentStepAttempts?.length
23
+ ? { attempts: run.currentStepAttempts }
24
+ : {}),
25
+ ...(run.currentStepOmittedAttempts
26
+ ? { omittedAttempts: run.currentStepOmittedAttempts }
27
+ : {}),
15
28
  completedAt: now,
16
29
  });
17
30
 
@@ -23,6 +36,7 @@ const completedStep = (
23
36
  * @param outcome - Submitted step outcome.
24
37
  * @param summary - Step handoff summary.
25
38
  * @param now - Update timestamp.
39
+ * @param effects - Validated declarative effects accepted with this result.
26
40
  * @returns A new paused, running, or completed workflow state.
27
41
  * @throws When the run, outcome, current step, or transition target is invalid.
28
42
  */
@@ -32,6 +46,7 @@ export const advanceRun = (
32
46
  outcome: string,
33
47
  summary: string,
34
48
  now: number,
49
+ effects: RunStepEffects = {},
35
50
  ): WorkflowRun => {
36
51
  if (run.status !== 'running') {
37
52
  throw new Error(
@@ -55,6 +70,14 @@ export const advanceRun = (
55
70
  `outcome "${outcome}" is not valid for step "${run.currentStepId}"`,
56
71
  );
57
72
  }
73
+ const shouldBindWorkspace = step.workspace?.bindOn.includes(outcome) ?? false;
74
+ if (shouldBindWorkspace !== Boolean(effects.workspaceCwd)) {
75
+ throw new Error(
76
+ shouldBindWorkspace
77
+ ? `outcome "${outcome}" requires a validated workspace binding`
78
+ : `outcome "${outcome}" cannot bind a workspace`,
79
+ );
80
+ }
58
81
  if (target === '$pause') {
59
82
  return withRunUpdate(
60
83
  run,
@@ -63,30 +86,35 @@ export const advanceRun = (
63
86
  pausedFrom: 'running',
64
87
  pauseReason: summary || `Step "${run.currentStepId}" requested a pause`,
65
88
  lastSummary: summary,
89
+ resumeInput: undefined,
66
90
  },
67
91
  now,
68
92
  );
69
93
  }
70
94
 
71
- const completed = completedStep(run, outcome, summary, now);
95
+ const completed = completedStep(run, outcome, summary, now, effects);
96
+ const cwd = effects.workspaceCwd ?? run.cwd;
72
97
  if (target === '$done') {
73
98
  return withRunUpdate(
74
99
  run,
75
100
  {
76
101
  status: 'completed',
77
102
  history: [...run.history, completed],
103
+ currentStepAttempts: undefined,
104
+ currentStepOmittedAttempts: undefined,
105
+ ...(cwd ? { cwd } : {}),
78
106
  stepHandoff: summary,
79
107
  lastSummary: summary,
80
108
  gateFeedback: '',
81
109
  pausedFrom: undefined,
82
110
  pendingGate: undefined,
111
+ resumeInput: undefined,
83
112
  },
84
113
  now,
85
114
  );
86
115
  }
87
116
 
88
- const nextStep = workflow.definition.steps[target];
89
- if (!nextStep) {
117
+ if (!workflow.definition.steps[target]) {
90
118
  throw new Error(`transition target "${target}" does not exist`);
91
119
  }
92
120
 
@@ -97,11 +125,13 @@ export const advanceRun = (
97
125
  status: 'paused',
98
126
  pausedFrom: 'running',
99
127
  pauseReason: `Step "${target}" exceeded maxStepVisits (${workflow.definition.maxStepVisits})`,
128
+ failedStepId: target,
100
129
  }
101
130
  : {
102
131
  status: 'running',
103
132
  pausedFrom: undefined,
104
133
  pauseReason: undefined,
134
+ failedStepId: undefined,
105
135
  };
106
136
 
107
137
  return withRunUpdate(
@@ -112,10 +142,13 @@ export const advanceRun = (
112
142
  currentStepDigest: workflow.stepDigests[target] ?? '',
113
143
  visits: { ...run.visits, [target]: nextVisitCount },
114
144
  history: [...run.history, completed],
145
+ currentStepAttempts: undefined,
146
+ currentStepOmittedAttempts: undefined,
147
+ ...(cwd ? { cwd } : {}),
115
148
  stepHandoff: summary,
116
149
  lastSummary: summary,
117
150
  gateFeedback: '',
118
- ...(nextStep.gate ? { reviewedArtifact: '' } : {}),
151
+ resumeInput: undefined,
119
152
  },
120
153
  now,
121
154
  );
@@ -98,6 +98,16 @@ export const resumeRun = (run: WorkflowRun, now: number): WorkflowRun => {
98
98
  );
99
99
  };
100
100
 
101
+ /**
102
+ * Replaces the user-authored guidance for one explicit resume attempt.
103
+ */
104
+ export const setResumeInput = (
105
+ run: WorkflowRun,
106
+ input: string,
107
+ now: number,
108
+ ): WorkflowRun =>
109
+ withRunUpdate(run, { resumeInput: input.trim() || undefined }, now);
110
+
101
111
  /**
102
112
  * Aborts a run and clears resumable gate state.
103
113
  *
@@ -119,6 +129,7 @@ export const abortRun = (
119
129
  pausedFrom: undefined,
120
130
  failedStepId: undefined,
121
131
  pendingGate: undefined,
132
+ resumeInput: undefined,
122
133
  },
123
134
  now,
124
135
  );
@@ -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,