@wichayutdew/pi-workflows 0.3.0 → 1.0.1

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 (144) hide show
  1. package/README.md +55 -39
  2. package/dist/index.js +5771 -4794
  3. package/examples/mr-comments.workflow.yaml +2 -0
  4. package/package.json +1 -1
  5. package/schemas/workflow.schema.json +10 -1
  6. package/src/command-names.ts +7 -1
  7. package/src/commands.ts +126 -52
  8. package/src/config/catalog.ts +162 -0
  9. package/src/config/ceiling.ts +120 -116
  10. package/src/config/command-conflicts.ts +4 -4
  11. package/src/config/diagnostics.ts +28 -0
  12. package/src/config/load-settings.ts +37 -0
  13. package/src/config/load-types.ts +53 -0
  14. package/src/config/load-workflows.ts +135 -0
  15. package/src/config/load.ts +43 -311
  16. package/src/config/types.ts +150 -137
  17. package/src/config/validate.ts +12 -1261
  18. package/src/config/validation/permissions.ts +312 -0
  19. package/src/config/validation/prompt.ts +21 -0
  20. package/src/config/validation/settings.ts +122 -0
  21. package/src/config/validation/shared.ts +129 -0
  22. package/src/config/validation/shortcut.ts +118 -0
  23. package/src/config/validation/step.ts +228 -0
  24. package/src/config/validation/subagent.ts +288 -0
  25. package/src/config/validation/workflow.ts +156 -0
  26. package/src/config/yaml.ts +29 -0
  27. package/src/digest.ts +38 -6
  28. package/src/engine/checkpoint.ts +13 -9
  29. package/src/engine/create-run.ts +43 -0
  30. package/src/engine/gate-transitions.ts +187 -0
  31. package/src/engine/reconciliation-history.ts +88 -0
  32. package/src/engine/resume.ts +19 -7
  33. package/src/engine/run-advance.ts +122 -0
  34. package/src/engine/run-lifecycle.ts +124 -0
  35. package/src/engine/run-reconciliation.ts +116 -0
  36. package/src/engine/run-validation.ts +129 -0
  37. package/src/engine/state-types.ts +66 -0
  38. package/src/engine/state.ts +10 -193
  39. package/src/engine/transition-helpers.ts +28 -0
  40. package/src/engine/transition-types.ts +8 -0
  41. package/src/engine/transitions.ts +17 -471
  42. package/src/harness/action-context.ts +181 -0
  43. package/src/harness/catalog.ts +45 -0
  44. package/src/harness/context-idle.ts +21 -0
  45. package/src/harness/core-actions.ts +299 -0
  46. package/src/harness/delegation-control-actions.ts +250 -0
  47. package/src/harness/delegation-failure.ts +215 -0
  48. package/src/harness/delegation-plan.ts +219 -0
  49. package/src/harness/delegation-recovery-validation.ts +159 -0
  50. package/src/harness/delegation-response-actions.ts +295 -0
  51. package/src/harness/delegation-retry-policy.ts +118 -0
  52. package/src/harness/dependencies.ts +165 -0
  53. package/src/harness/gate-submission-action.ts +154 -0
  54. package/src/harness/lifecycle-actions.ts +154 -0
  55. package/src/harness/pause-actions.ts +120 -0
  56. package/src/harness/plannotator-result-actions.ts +123 -0
  57. package/src/harness/prompt-gate-actions.ts +270 -0
  58. package/src/harness/resume-action.ts +261 -0
  59. package/src/harness/start-actions.ts +183 -0
  60. package/src/harness/status-actions.ts +191 -0
  61. package/src/harness/step-execution-actions.ts +283 -0
  62. package/src/harness/types.ts +76 -0
  63. package/src/harness.ts +236 -2303
  64. package/src/index.ts +73 -11
  65. package/src/integrations/plannotator-requests.ts +127 -0
  66. package/src/integrations/plannotator-responses.ts +141 -0
  67. package/src/integrations/plannotator-types.ts +44 -0
  68. package/src/integrations/plannotator.ts +16 -235
  69. package/src/integrations/prompt-gate.ts +17 -10
  70. package/src/integrations/subagents/child-policy-envelope.ts +119 -0
  71. package/src/integrations/subagents/child-policy-paths.ts +66 -0
  72. package/src/integrations/subagents/child-policy-sections.ts +186 -0
  73. package/src/integrations/subagents/child-policy-types.ts +32 -0
  74. package/src/integrations/subagents/child-policy-validation.ts +163 -0
  75. package/src/integrations/subagents/child-runtime-completion.ts +56 -0
  76. package/src/integrations/subagents/child-runtime-dependencies.ts +47 -0
  77. package/src/integrations/subagents/child-runtime-files.ts +156 -0
  78. package/src/integrations/subagents/child-runtime-policy.ts +83 -0
  79. package/src/integrations/subagents/child-runtime-types.ts +30 -0
  80. package/src/integrations/subagents/child-runtime.ts +140 -301
  81. package/src/integrations/subagents/client-delegation.ts +181 -0
  82. package/src/integrations/subagents/client-messages.ts +66 -0
  83. package/src/integrations/subagents/client-types.ts +36 -0
  84. package/src/integrations/subagents/client.ts +127 -219
  85. package/src/integrations/subagents/delegated-result.ts +31 -0
  86. package/src/integrations/subagents/diagnostic-format.ts +40 -0
  87. package/src/integrations/subagents/diagnostic-text.ts +114 -0
  88. package/src/integrations/subagents/diagnostic-types.ts +77 -0
  89. package/src/integrations/subagents/diagnostics.ts +24 -977
  90. package/src/integrations/subagents/failure-correlation.ts +235 -0
  91. package/src/integrations/subagents/failure-transcript.ts +192 -0
  92. package/src/integrations/subagents/hidden-bash-failure.ts +98 -0
  93. package/src/integrations/subagents/protocol-events.ts +27 -0
  94. package/src/integrations/subagents/protocol.ts +30 -375
  95. package/src/integrations/subagents/replay-audit.ts +147 -0
  96. package/src/integrations/subagents/replay-safety.ts +82 -0
  97. package/src/integrations/subagents/session-diagnostics.ts +258 -0
  98. package/src/policy/approved-command-extraction.ts +71 -0
  99. package/src/policy/approved-commands.ts +15 -432
  100. package/src/policy/bash-authorization.ts +86 -0
  101. package/src/policy/bash-read-only.ts +161 -0
  102. package/src/policy/bash-types.ts +20 -0
  103. package/src/policy/bash.ts +4 -346
  104. package/src/policy/completion-batch.ts +21 -16
  105. package/src/policy/immutable-input.ts +5 -2
  106. package/src/policy/mcp-authorization.ts +71 -0
  107. package/src/policy/restricted-command.ts +141 -0
  108. package/src/policy/restricted-git.ts +30 -0
  109. package/src/policy/reviewed-artifact.ts +97 -0
  110. package/src/policy/reviewed-command-safety.ts +150 -0
  111. package/src/policy/reviewed-command-shape.ts +49 -0
  112. package/src/policy/reviewed-repository-contract.ts +138 -0
  113. package/src/policy/reviewed-repository-cwd.ts +102 -0
  114. package/src/policy/tool-call-authorization.ts +58 -0
  115. package/src/policy/tool-selection.ts +72 -0
  116. package/src/policy/tool-types.ts +14 -0
  117. package/src/policy/tools.ts +11 -150
  118. package/src/preflight.ts +96 -61
  119. package/src/prompt/main-workflow-notice.ts +41 -0
  120. package/src/prompt/retry-task.ts +83 -0
  121. package/src/prompt/step-contract.ts +101 -0
  122. package/src/prompt/step-sections.ts +75 -0
  123. package/src/prompt/step-task.ts +156 -0
  124. package/src/prompt/template.ts +83 -0
  125. package/src/prompt.ts +10 -254
  126. package/src/runtime/completion-tool.ts +3 -0
  127. package/src/runtime/main-step-completion.ts +78 -0
  128. package/src/runtime/main-step-lifecycle.ts +37 -0
  129. package/src/runtime/main-step-policy.ts +84 -0
  130. package/src/runtime/main-step-runtime-types.ts +64 -0
  131. package/src/runtime/main-step-runtime.ts +129 -200
  132. package/src/runtime/main-step-state.ts +85 -0
  133. package/src/runtime/serial-task-queue.ts +48 -13
  134. package/src/runtime/step-result.ts +48 -27
  135. package/src/workflow-list.ts +15 -6
  136. package/src/workflow-status/format-status.ts +52 -0
  137. package/src/workflow-status/formatting.ts +178 -0
  138. package/src/workflow-status/layout.ts +109 -0
  139. package/src/workflow-status/render-board.ts +101 -0
  140. package/src/workflow-status/render-path.ts +123 -0
  141. package/src/workflow-status/render-summary.ts +144 -0
  142. package/src/workflow-status/types.ts +40 -0
  143. package/src/workflow-status/view.ts +273 -0
  144. package/src/workflow-status.ts +20 -820
package/src/digest.ts CHANGED
@@ -1,23 +1,55 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
- function canonicalize(value: unknown): unknown {
3
+ /**
4
+ * Computes a deterministic digest for an unknown value.
5
+ */
6
+ export type DigestFunction = (value: unknown) => string;
7
+
8
+ /**
9
+ * Effect boundary used to hash a canonical serialized value.
10
+ */
11
+ export type DigestDependencies = {
12
+ readonly hash: (serializedValue: string) => string;
13
+ };
14
+
15
+ const canonicalize = (value: unknown): unknown => {
4
16
  if (Array.isArray(value)) {
5
17
  return value.map(canonicalize);
6
18
  }
7
19
 
8
20
  if (value !== null && typeof value === 'object') {
9
21
  return Object.fromEntries(
10
- Object.entries(value as Record<string, unknown>)
11
- .sort(([left], [right]) => left.localeCompare(right))
22
+ Object.entries(value)
23
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
12
24
  .map(([key, child]) => [key, canonicalize(child)]),
13
25
  );
14
26
  }
15
27
 
16
28
  return value;
29
+ };
30
+
31
+ const sha256 = (serializedValue: string): string =>
32
+ createHash('sha256').update(serializedValue).digest('hex');
33
+
34
+ /**
35
+ * Creates a deterministic value-digest function around an injected hash
36
+ * implementation.
37
+ *
38
+ * @param dependencies - Hash effect used after canonical serialization.
39
+ * @returns A deterministic digest function.
40
+ */
41
+ export function createDigest({ hash }: DigestDependencies): DigestFunction {
42
+ return (value: unknown): string => hash(JSON.stringify(canonicalize(value)));
17
43
  }
18
44
 
45
+ const defaultDigest = createDigest({ hash: sha256 });
46
+
47
+ /**
48
+ * Produces a stable SHA-256 digest independent of object key insertion order.
49
+ *
50
+ * @param value - JSON-compatible value to digest.
51
+ * @returns Lowercase hexadecimal SHA-256 digest.
52
+ */
19
53
  export function digest(value: unknown): string {
20
- return createHash('sha256')
21
- .update(JSON.stringify(canonicalize(value)))
22
- .digest('hex');
54
+ return defaultDigest(value);
23
55
  }
@@ -1,22 +1,26 @@
1
1
  import { isWorkflowRun, type WorkflowRun } from './state.ts';
2
2
 
3
3
  export type CheckpointResult =
4
- | { status: 'none' }
5
- | { status: 'invalid' }
6
- | { status: 'valid'; run: WorkflowRun };
4
+ | { readonly status: 'none' }
5
+ | { readonly status: 'invalid' }
6
+ | { readonly status: 'valid'; readonly run: WorkflowRun };
7
7
 
8
- interface SessionEntryLike {
9
- type?: unknown;
10
- customType?: unknown;
11
- data?: unknown;
12
- }
8
+ type SessionEntryLike = {
9
+ readonly type?: unknown;
10
+ readonly customType?: unknown;
11
+ readonly data?: unknown;
12
+ };
13
13
 
14
14
  /**
15
15
  * Only the newest entry for this checkpoint type is authoritative. Falling
16
16
  * back past corrupt or newer-version state could repeat already-finished work.
17
+ *
18
+ * @param entries - Session entries in chronological order.
19
+ * @param customType - Workflow checkpoint entry type.
20
+ * @returns The newest checkpoint state.
17
21
  */
18
22
  export function readLatestCheckpoint(
19
- entries: readonly SessionEntryLike[],
23
+ entries: ReadonlyArray<SessionEntryLike>,
20
24
  customType: string,
21
25
  ): CheckpointResult {
22
26
  for (let index = entries.length - 1; index >= 0; index -= 1) {
@@ -0,0 +1,43 @@
1
+ import type { LoadedWorkflow } from '../config/types.ts';
2
+ import { RUN_STATE_VERSION } from './state-types.ts';
3
+ import type { WorkflowRun } from './state-types.ts';
4
+
5
+ /**
6
+ * Creates the immutable initial state for a workflow run.
7
+ *
8
+ * @param workflow - Loaded workflow definition and digests.
9
+ * @param input - User input that started the workflow.
10
+ * @param baselineTools - Tool names available before workflow restrictions.
11
+ * @param runId - Stable run identifier.
12
+ * @param now - Creation timestamp.
13
+ * @returns A new running workflow state.
14
+ */
15
+ export const createRun = (
16
+ workflow: LoadedWorkflow,
17
+ input: string,
18
+ baselineTools: ReadonlyArray<string>,
19
+ runId: string,
20
+ now: number,
21
+ ): WorkflowRun => {
22
+ const startStepId = workflow.definition.start;
23
+ return {
24
+ stateVersion: RUN_STATE_VERSION,
25
+ runId,
26
+ workflowId: workflow.definition.id,
27
+ workflowDigest: workflow.digest,
28
+ input,
29
+ status: 'running',
30
+ currentStepId: startStepId,
31
+ currentStepDigest: workflow.stepDigests[startStepId] ?? '',
32
+ baselineTools: [...new Set(baselineTools)],
33
+ visits: { [startStepId]: 1 },
34
+ history: [],
35
+ startedAt: now,
36
+ updatedAt: now,
37
+ reviewedArtifact: '',
38
+ reviewedFeedback: '',
39
+ stepHandoff: '',
40
+ lastSummary: '',
41
+ gateFeedback: '',
42
+ };
43
+ };
@@ -0,0 +1,187 @@
1
+ import type { LoadedWorkflow } from '../config/types.ts';
2
+ import { advanceRun } from './run-advance.ts';
3
+ import type { GateResolution, WorkflowRun } from './state-types.ts';
4
+ import { currentStep, withRunUpdate } from './transition-helpers.ts';
5
+
6
+ /**
7
+ * Begins human review for a gated workflow step.
8
+ *
9
+ * @param workflow - Loaded workflow.
10
+ * @param run - Current running workflow state.
11
+ * @param outcome - Submitted gate outcome.
12
+ * @param artifact - Artifact to display for review.
13
+ * @param requestId - Stable gate request identifier.
14
+ * @param now - Update timestamp.
15
+ * @returns A new awaiting-gate workflow state.
16
+ * @throws When the run or gate submission is invalid.
17
+ */
18
+ export const beginGate = (
19
+ workflow: LoadedWorkflow,
20
+ run: WorkflowRun,
21
+ outcome: string,
22
+ artifact: string,
23
+ requestId: string,
24
+ now: number,
25
+ ): WorkflowRun => {
26
+ if (run.status !== 'running') {
27
+ throw new Error(
28
+ `workflow is ${run.status}; gate submission requires a running workflow`,
29
+ );
30
+ }
31
+ const step = currentStep(workflow, run);
32
+ if (!step?.gate) {
33
+ throw new Error(`step "${run.currentStepId}" has no gate`);
34
+ }
35
+ if (outcome !== step.gate.submitOutcome) {
36
+ throw new Error(`gate expects outcome "${step.gate.submitOutcome}"`);
37
+ }
38
+ if (!artifact.trim()) {
39
+ throw new Error('gate submission requires a non-empty artifact');
40
+ }
41
+ if (!requestId) throw new Error('gate submission requires a request id');
42
+
43
+ return withRunUpdate(
44
+ run,
45
+ {
46
+ status: 'awaiting-gate',
47
+ pendingGate: {
48
+ provider: step.gate.provider,
49
+ requestId,
50
+ stepId: run.currentStepId,
51
+ artifact,
52
+ submittedOutcome: outcome,
53
+ requestedAt: now,
54
+ },
55
+ },
56
+ now,
57
+ );
58
+ };
59
+
60
+ /**
61
+ * Attaches the provider review identifier to a pending Plannotator gate.
62
+ *
63
+ * @param run - Current workflow state.
64
+ * @param reviewId - Provider review identifier.
65
+ * @param now - Update timestamp.
66
+ * @returns A new workflow state with the review identifier.
67
+ * @throws When no Plannotator gate is pending.
68
+ */
69
+ export const attachGateReviewId = (
70
+ run: WorkflowRun,
71
+ reviewId: string,
72
+ now: number,
73
+ ): WorkflowRun => {
74
+ if (!run.pendingGate) throw new Error('workflow has no pending gate');
75
+ if (run.pendingGate.provider !== 'plannotator') {
76
+ throw new Error('only a Plannotator gate can have a review id');
77
+ }
78
+ return withRunUpdate(
79
+ run,
80
+ { pendingGate: { ...run.pendingGate, reviewId } },
81
+ now,
82
+ );
83
+ };
84
+
85
+ /**
86
+ * Returns a failed gate request to active step execution with feedback.
87
+ *
88
+ * @param run - Current workflow state.
89
+ * @param reason - Gate failure feedback.
90
+ * @param now - Update timestamp.
91
+ * @returns A running state, or the original state when no gate is pending.
92
+ */
93
+ export const failGate = (
94
+ run: WorkflowRun,
95
+ reason: string,
96
+ now: number,
97
+ ): WorkflowRun =>
98
+ run.pendingGate
99
+ ? withRunUpdate(
100
+ run,
101
+ {
102
+ status: 'running',
103
+ pendingGate: undefined,
104
+ gateFeedback: reason,
105
+ },
106
+ now,
107
+ )
108
+ : run;
109
+
110
+ /**
111
+ * Stores a gate resolution without advancing the workflow.
112
+ *
113
+ * @param run - Current workflow state.
114
+ * @param resolution - Human review resolution.
115
+ * @param now - Update timestamp.
116
+ * @returns Updated gate state, or the original state when no gate is pending.
117
+ */
118
+ export const storeGateResolution = (
119
+ run: WorkflowRun,
120
+ resolution: GateResolution,
121
+ now: number,
122
+ ): WorkflowRun =>
123
+ run.pendingGate
124
+ ? withRunUpdate(
125
+ run,
126
+ { pendingGate: { ...run.pendingGate, resolution } },
127
+ now,
128
+ )
129
+ : run;
130
+
131
+ /**
132
+ * Applies a human gate decision and follows its configured transition.
133
+ *
134
+ * @param workflow - Loaded workflow.
135
+ * @param run - Current awaiting-gate workflow state.
136
+ * @param resolution - Human review resolution.
137
+ * @param now - Update timestamp.
138
+ * @returns The workflow state after the configured gate transition.
139
+ * @throws When pending gate state no longer matches the workflow.
140
+ */
141
+ export const resolveGate = (
142
+ workflow: LoadedWorkflow,
143
+ run: WorkflowRun,
144
+ resolution: GateResolution,
145
+ now: number,
146
+ ): WorkflowRun => {
147
+ const pendingGate = run.pendingGate;
148
+ if (!pendingGate) throw new Error('workflow has no pending gate');
149
+
150
+ const step = workflow.definition.steps[pendingGate.stepId];
151
+ if (!step?.gate) {
152
+ throw new Error(`gated step "${pendingGate.stepId}" no longer exists`);
153
+ }
154
+ if (run.currentStepId !== pendingGate.stepId) {
155
+ throw new Error('gate result does not match the current step');
156
+ }
157
+
158
+ const outcome = resolution.approved
159
+ ? step.gate.approvedOutcome
160
+ : step.gate.rejectedOutcome;
161
+ const summary = resolution.approved
162
+ ? pendingGate.artifact
163
+ : resolution.feedback
164
+ ? `Gate rejected: ${resolution.feedback}`
165
+ : 'Gate rejected';
166
+ const runnableRun = withRunUpdate(
167
+ run,
168
+ {
169
+ status: 'running',
170
+ pendingGate: undefined,
171
+ ...(resolution.approved
172
+ ? {
173
+ reviewedArtifact: pendingGate.artifact,
174
+ reviewedFeedback: resolution.feedback,
175
+ }
176
+ : {}),
177
+ pausedFrom: undefined,
178
+ pauseReason: undefined,
179
+ gateFeedback: resolution.feedback,
180
+ },
181
+ now,
182
+ );
183
+ return {
184
+ ...advanceRun(workflow, runnableRun, outcome, summary, now),
185
+ gateFeedback: resolution.feedback,
186
+ };
187
+ };
@@ -0,0 +1,88 @@
1
+ import type { LoadedWorkflow } from '../config/types.ts';
2
+ import type { StepHistoryEntry, WorkflowRun } from './state-types.ts';
3
+
4
+ /**
5
+ * Rebuilds step visit counts from retained history and the current step.
6
+ *
7
+ * @param history - Retained completed-step history.
8
+ * @param currentStepId - Step about to run.
9
+ * @returns Visit counts for the reconciled state.
10
+ */
11
+ export const rebuildVisits = (
12
+ history: ReadonlyArray<StepHistoryEntry>,
13
+ currentStepId: string,
14
+ ): Readonly<Record<string, number>> => {
15
+ const visitedStepIds = [
16
+ ...history.map((entry) => entry.stepId),
17
+ currentStepId,
18
+ ];
19
+ return visitedStepIds.reduce<Record<string, number>>(
20
+ (visits, stepId) => ({
21
+ ...visits,
22
+ [stepId]: (visits[stepId] ?? 0) + 1,
23
+ }),
24
+ {},
25
+ );
26
+ };
27
+
28
+ /**
29
+ * Retains reviewed authority only when its approving history remains.
30
+ *
31
+ * @param workflow - Updated workflow.
32
+ * @param run - Existing workflow state.
33
+ * @param history - History retained after reconciliation.
34
+ * @returns The retained artifact, or an empty string when provenance was lost.
35
+ */
36
+ export const retainedReviewedArtifact = (
37
+ workflow: LoadedWorkflow,
38
+ run: WorkflowRun,
39
+ history: ReadonlyArray<StepHistoryEntry>,
40
+ ): string => {
41
+ const reviewedArtifact = run.reviewedArtifact ?? '';
42
+ if (!reviewedArtifact) return '';
43
+
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 : '';
53
+ };
54
+
55
+ /**
56
+ * Refreshes the digest of retained approved-gate history.
57
+ *
58
+ * The reviewed artifact itself proves the gate output is unchanged, allowing a
59
+ * prompt-only configuration refresh without discarding approved authority.
60
+ *
61
+ * @param run - Existing workflow state.
62
+ * @param workflow - Updated workflow.
63
+ * @returns A run with refreshed history when needed.
64
+ */
65
+ export const refreshApprovedGateHistory = (
66
+ run: WorkflowRun,
67
+ workflow: LoadedWorkflow,
68
+ ): WorkflowRun => {
69
+ const reviewedArtifact = run.reviewedArtifact ?? '';
70
+ if (!reviewedArtifact) return run;
71
+
72
+ const history = run.history.map((entry) => {
73
+ const gate = workflow.definition.steps[entry.stepId]?.gate;
74
+ const currentDigest = workflow.stepDigests[entry.stepId];
75
+ const shouldRefresh =
76
+ gate !== undefined &&
77
+ typeof currentDigest === 'string' &&
78
+ currentDigest.length > 0 &&
79
+ entry.outcome === gate.approvedOutcome &&
80
+ entry.summary === reviewedArtifact &&
81
+ entry.stepDigest !== currentDigest;
82
+ return shouldRefresh ? { ...entry, stepDigest: currentDigest } : entry;
83
+ });
84
+ const hasChanged = history.some(
85
+ (entry, index) => entry !== run.history[index],
86
+ );
87
+ return hasChanged ? { ...run, history } : run;
88
+ };
@@ -1,13 +1,20 @@
1
1
  import type { WorkflowRun } from './state.ts';
2
2
 
3
- export interface ResumeCheckpoint {
4
- sessionEpoch: number;
5
- runId: string;
6
- workflowId: string;
7
- currentStepId: string;
8
- reviewId?: string;
9
- }
3
+ export type ResumeCheckpoint = {
4
+ readonly sessionEpoch: number;
5
+ readonly runId: string;
6
+ readonly workflowId: string;
7
+ readonly currentStepId: string;
8
+ readonly reviewId?: string;
9
+ };
10
10
 
11
+ /**
12
+ * Captures the identity fields that asynchronous resume work must preserve.
13
+ *
14
+ * @param run - Paused workflow run.
15
+ * @param sessionEpoch - Current session-tree epoch.
16
+ * @returns A checkpoint suitable for a later identity comparison.
17
+ */
11
18
  export function captureResumeCheckpoint(
12
19
  run: WorkflowRun,
13
20
  sessionEpoch: number,
@@ -27,6 +34,11 @@ export function captureResumeCheckpoint(
27
34
  * Async resume work may overlap an abort, a session-tree switch, or a gate
28
35
  * result. A gate result may update the same paused checkpoint and is safe to
29
36
  * merge; a different session, run, step, review, or status is not.
37
+ *
38
+ * @param run - Current workflow state.
39
+ * @param sessionEpoch - Current session-tree epoch.
40
+ * @param checkpoint - Identity captured before asynchronous work.
41
+ * @returns `true` when the paused run still matches the checkpoint.
30
42
  */
31
43
  export function matchesResumeCheckpoint(
32
44
  run: WorkflowRun | undefined,
@@ -0,0 +1,122 @@
1
+ import type { LoadedWorkflow } from '../config/types.ts';
2
+ import type { StepHistoryEntry, WorkflowRun } from './state-types.ts';
3
+ import { currentStep, withRunUpdate } from './transition-helpers.ts';
4
+
5
+ const completedStep = (
6
+ run: WorkflowRun,
7
+ outcome: string,
8
+ summary: string,
9
+ now: number,
10
+ ): StepHistoryEntry => ({
11
+ stepId: run.currentStepId,
12
+ stepDigest: run.currentStepDigest,
13
+ outcome,
14
+ summary,
15
+ completedAt: now,
16
+ });
17
+
18
+ /**
19
+ * Advances a running workflow through a configured non-gate transition.
20
+ *
21
+ * @param workflow - Loaded workflow.
22
+ * @param run - Current running workflow state.
23
+ * @param outcome - Submitted step outcome.
24
+ * @param summary - Step handoff summary.
25
+ * @param now - Update timestamp.
26
+ * @returns A new paused, running, or completed workflow state.
27
+ * @throws When the run, outcome, current step, or transition target is invalid.
28
+ */
29
+ export const advanceRun = (
30
+ workflow: LoadedWorkflow,
31
+ run: WorkflowRun,
32
+ outcome: string,
33
+ summary: string,
34
+ now: number,
35
+ ): WorkflowRun => {
36
+ if (run.status !== 'running') {
37
+ throw new Error(
38
+ `workflow is ${run.status}; only a running workflow can advance`,
39
+ );
40
+ }
41
+
42
+ const step = currentStep(workflow, run);
43
+ if (!step) {
44
+ throw new Error(`current step "${run.currentStepId}" no longer exists`);
45
+ }
46
+ if (step.gate?.submitOutcome === outcome) {
47
+ throw new Error(
48
+ `outcome "${outcome}" must be submitted through the configured gate`,
49
+ );
50
+ }
51
+
52
+ const target = step.transitions[outcome];
53
+ if (!target) {
54
+ throw new Error(
55
+ `outcome "${outcome}" is not valid for step "${run.currentStepId}"`,
56
+ );
57
+ }
58
+ if (target === '$pause') {
59
+ return withRunUpdate(
60
+ run,
61
+ {
62
+ status: 'paused',
63
+ pausedFrom: 'running',
64
+ pauseReason: summary || `Step "${run.currentStepId}" requested a pause`,
65
+ lastSummary: summary,
66
+ },
67
+ now,
68
+ );
69
+ }
70
+
71
+ const completed = completedStep(run, outcome, summary, now);
72
+ if (target === '$done') {
73
+ return withRunUpdate(
74
+ run,
75
+ {
76
+ status: 'completed',
77
+ history: [...run.history, completed],
78
+ stepHandoff: summary,
79
+ lastSummary: summary,
80
+ gateFeedback: '',
81
+ pausedFrom: undefined,
82
+ pendingGate: undefined,
83
+ },
84
+ now,
85
+ );
86
+ }
87
+
88
+ const nextStep = workflow.definition.steps[target];
89
+ if (!nextStep) {
90
+ throw new Error(`transition target "${target}" does not exist`);
91
+ }
92
+
93
+ const nextVisitCount = (run.visits[target] ?? 0) + 1;
94
+ const isOverVisitLimit = nextVisitCount > workflow.definition.maxStepVisits;
95
+ const visitLimitChanges: Partial<WorkflowRun> = isOverVisitLimit
96
+ ? {
97
+ status: 'paused',
98
+ pausedFrom: 'running',
99
+ pauseReason: `Step "${target}" exceeded maxStepVisits (${workflow.definition.maxStepVisits})`,
100
+ }
101
+ : {
102
+ status: 'running',
103
+ pausedFrom: undefined,
104
+ pauseReason: undefined,
105
+ };
106
+
107
+ return withRunUpdate(
108
+ run,
109
+ {
110
+ ...visitLimitChanges,
111
+ currentStepId: target,
112
+ currentStepDigest: workflow.stepDigests[target] ?? '',
113
+ visits: { ...run.visits, [target]: nextVisitCount },
114
+ history: [...run.history, completed],
115
+ stepHandoff: summary,
116
+ lastSummary: summary,
117
+ gateFeedback: '',
118
+ ...(nextStep.gate ? { reviewedArtifact: '', reviewedFeedback: '' } : {}),
119
+ },
120
+ now,
121
+ );
122
+ };
@@ -0,0 +1,124 @@
1
+ import type { LoadedWorkflow } from '../config/types.ts';
2
+ import type { WorkflowRun } from './state-types.ts';
3
+ import { currentStep, withRunUpdate } from './transition-helpers.ts';
4
+
5
+ /**
6
+ * Lists outcomes the active step may submit directly.
7
+ *
8
+ * Gate resolution outcomes are reserved for the gate; only its submit outcome
9
+ * is exposed to step execution.
10
+ *
11
+ * @param workflow - Loaded workflow.
12
+ * @param run - Current workflow state.
13
+ * @returns Allowed outcome names.
14
+ */
15
+ export const allowedOutcomes = (
16
+ workflow: LoadedWorkflow,
17
+ run: WorkflowRun,
18
+ ): Array<string> => {
19
+ const step = currentStep(workflow, run);
20
+ if (!step) return [];
21
+
22
+ const gateResolutionOutcomes = step.gate
23
+ ? new Set([step.gate.approvedOutcome, step.gate.rejectedOutcome])
24
+ : undefined;
25
+ return [
26
+ ...Object.keys(step.transitions).filter(
27
+ (outcome) => !gateResolutionOutcomes?.has(outcome),
28
+ ),
29
+ ...(step.gate ? [step.gate.submitOutcome] : []),
30
+ ];
31
+ };
32
+
33
+ /**
34
+ * Pauses an active run while preserving its previous execution mode.
35
+ *
36
+ * @param run - Current workflow state.
37
+ * @param reason - User-facing pause reason.
38
+ * @param now - Update timestamp.
39
+ * @returns A new paused workflow state.
40
+ */
41
+ export const pauseRun = (
42
+ run: WorkflowRun,
43
+ reason: string,
44
+ now: number,
45
+ ): WorkflowRun => {
46
+ if (run.status !== 'running' && run.status !== 'awaiting-gate') {
47
+ return withRunUpdate(run, { pauseReason: reason || run.pauseReason }, now);
48
+ }
49
+ return withRunUpdate(
50
+ run,
51
+ {
52
+ status: 'paused',
53
+ pausedFrom: run.status,
54
+ pauseReason: reason || `Paused during step "${run.currentStepId}"`,
55
+ failedStepId: undefined,
56
+ },
57
+ now,
58
+ );
59
+ };
60
+
61
+ /**
62
+ * Pauses a run and marks its current step as failed for a resumable retry.
63
+ *
64
+ * @param run - Current workflow state.
65
+ * @param reason - Failure reason.
66
+ * @param now - Update timestamp.
67
+ * @returns A new failed-and-paused workflow state.
68
+ */
69
+ export const failRun = (
70
+ run: WorkflowRun,
71
+ reason: string,
72
+ now: number,
73
+ ): WorkflowRun => {
74
+ const pausedRun = pauseRun(run, reason, now);
75
+ return pausedRun.status === 'paused'
76
+ ? { ...pausedRun, failedStepId: pausedRun.currentStepId }
77
+ : pausedRun;
78
+ };
79
+
80
+ /**
81
+ * Resumes a paused run in the mode from which it was paused.
82
+ *
83
+ * @param run - Current workflow state.
84
+ * @param now - Update timestamp.
85
+ * @returns A resumed state, or the unchanged state when it was not paused.
86
+ */
87
+ export const resumeRun = (run: WorkflowRun, now: number): WorkflowRun => {
88
+ if (run.status !== 'paused') return run;
89
+ return withRunUpdate(
90
+ run,
91
+ {
92
+ status: run.pausedFrom ?? (run.pendingGate ? 'awaiting-gate' : 'running'),
93
+ pauseReason: undefined,
94
+ pausedFrom: undefined,
95
+ failedStepId: undefined,
96
+ },
97
+ now,
98
+ );
99
+ };
100
+
101
+ /**
102
+ * Aborts a run and clears resumable gate state.
103
+ *
104
+ * @param run - Current workflow state.
105
+ * @param reason - User-facing abort reason.
106
+ * @param now - Update timestamp.
107
+ * @returns A new aborted workflow state.
108
+ */
109
+ export const abortRun = (
110
+ run: WorkflowRun,
111
+ reason: string,
112
+ now: number,
113
+ ): WorkflowRun =>
114
+ withRunUpdate(
115
+ run,
116
+ {
117
+ status: 'aborted',
118
+ pauseReason: reason || 'Aborted by user',
119
+ pausedFrom: undefined,
120
+ failedStepId: undefined,
121
+ pendingGate: undefined,
122
+ },
123
+ now,
124
+ );