Repository navigation
Expand file tree
/
Copy pathapproval-engine.ts
More file actions
213 lines (195 loc) · 8.28 KB
/
Copy pathapproval-engine.ts
File metadata and controls
213 lines (195 loc) · 8.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
import {
type ApprovalWorkflow,
approversOf,
describeCondition,
evaluateCondition,
type InvoiceContext,
type WorkflowStep,
} from "@/lib/approval-workflow";
import { nonNull } from "@/lib/assert";
/**
* Approval workflow engine, walk the DAG for one invoice and compute the state
* of every step. Pure and deterministic: (workflow, invoice context, the human
* decisions so far) → an execution snapshot. No side effects here; actually
* posting to NetSuite / firing a Slack message is the caller's job (the workflow
* step), driven off this snapshot. That keeps the routing logic exhaustively
* unit-testable and the I/O at the edge.
*
* Execution model, parallel fan-out with collect-all human gates:
* • A step is REACHED when all its incoming edges come from steps that have
* completed (approved / skipped / done). Roots are reached immediately.
* • A reached step whose `when` condition is false is SKIPPED (and so are the
* branches that only it feeds, unless another active step also feeds them).
* • A reached approval step with a true condition is PENDING until the human
* decides it; the engine surfaces ALL currently-pending approvals at once
* (the parallel "In progress" steps you see fanned out), and the run waits
* until every one is resolved before the steps after them run.
* • One rejection blocks everything downstream (and the final post never runs).
* • A reached integration step with a true condition runs (status `done`)
* once it's unblocked.
*/
/** The lifecycle state of a single step for a given invoice + decision set. */
type StepStatus =
| "pending" // approval step waiting on a human (the parallel "In progress" nodes)
| "approved" // approval step a human approved
| "rejected" // approval step a human rejected, blocks downstream
| "skipped" // condition was false (or upstream didn't activate it)
| "done" // integration step that ran
| "blocked"; // couldn't run because an upstream step was rejected
export type StepState = {
id: string;
status: StepStatus;
/** Human-readable reason, for the trace/UI. */
detail: string;
};
/** The human's decision on a given approval step id. */
type StepDecision = "approve" | "reject";
export type Decisions = Record<string, StepDecision>;
/** An optional note the reviewer attached when rejecting a gate, by step id. Sparse,
only rejects carry one. Kept parallel to `Decisions` so the decision stays a bare
enum everywhere it flows. */
export type Reasons = Record<string, string>;
export type ExecutionState = {
steps: StepState[];
/** Approval step ids currently waiting on a human (collect-all). */
pending: string[];
/** Overall: are we waiting, finished clean, or blocked by a rejection. */
outcome: "awaiting" | "approved" | "rejected";
};
/**
* Compute the execution snapshot. Walks the DAG in topological waves: a step is
* only evaluated once all its predecessors have settled, so a pending approval
* holds back everything behind it (the run pauses), while parallel siblings are
* all surfaced together.
*/
export const executeWorkflow = (
workflow: ApprovalWorkflow,
ctx: InvoiceContext,
decisions: Decisions = {},
reasons: Reasons = {}
): ExecutionState => {
const byId = new Map<string, WorkflowStep>(workflow.steps.map((s) => [s.id, s]));
// Reverse edges: who feeds into each step (its predecessors).
const predecessors = new Map<string, string[]>();
for (const s of workflow.steps) predecessors.set(s.id, []);
for (const s of workflow.steps) {
for (const n of s.next) predecessors.get(n)?.push(s.id);
}
const state = new Map<string, StepState>();
// Resolve a single step's state given its predecessors are already resolved.
//
// Join semantics (AND-join, which is what the fan-out-then-rejoin template
// means): a step waits for ALL its predecessor PATHS to settle. A predecessor
// is "settled-passed" when it's approved/done/skipped, a skipped gate is a
// transparent pass-through (the gate didn't apply), NOT a dead branch, so flow
// continues past it. A predecessor that is pending/blocked means not-yet; a
// rejected predecessor hard-stops everything behind it.
const resolve = (step: WorkflowStep): StepState => {
const preds = predecessors.get(step.id) ?? [];
// Predecessors are resolved before this step (topo order), so each is present.
const predStates = preds.map((p) =>
nonNull(state.get(p), `predecessor ${p} resolved before ${step.id}`)
);
const isAnyPredRejected = predStates.some((p) => p.status === "rejected");
// A rejection anywhere upstream blocks this step (the bill won't post).
if (isAnyPredRejected) {
return {
id: step.id,
status: "blocked",
detail: "Blocked, an upstream approval was rejected.",
};
}
const isAnyPredWaiting = predStates.some(
(p) => p.status === "pending" || p.status === "blocked"
);
// Any predecessor still pending/blocked → not reached yet; recompute after the
// human acts. (For the AND-join, ALL paths must settle before we proceed.)
if (isAnyPredWaiting) {
return {
id: step.id,
status: "blocked",
detail: "Waiting on an earlier approval before this step can run.",
};
}
// All predecessors settled-and-passed (approved/done/skipped). Roots have none
// and are always reached. Now the step's own condition decides skip vs run.
const condText = describeCondition(step.when);
if (!evaluateCondition(step.when, ctx)) {
return {
id: step.id,
status: "skipped",
detail: `Skipped, condition not met (${condText}).`,
};
}
if (step.kind === "integration") {
return {
id: step.id,
status: "done",
detail: `Ran ${step.integration}.`,
};
}
// Approval step, condition true → look at the human decision. `who` names every
// approver on the gate (primary + extras), or the role if none is resolved yet.
const decision = decisions[step.id];
const who = approversOf(step).join(", ") || step.approverTitle;
if (decision === "approve") {
return {
id: step.id,
status: "approved",
detail: `Approved by ${who}.`,
};
}
if (decision === "reject") {
const reason = reasons[step.id]?.trim();
return {
id: step.id,
status: "rejected",
detail: reason ? `Rejected by ${who}: ${reason}` : `Rejected by ${who}.`,
};
}
return {
id: step.id,
status: "pending",
detail: `Awaiting ${who}${condText === "always" ? "" : ` (${condText})`}.`,
};
};
// Topological order (the DAG is small; Kahn's algorithm). Then resolve in order
// so every step sees settled predecessors.
for (const id of topoOrder(workflow)) {
const step = byId.get(id);
if (step) state.set(id, resolve(step));
}
// Every step was resolved in the loop above, so each has a state.
const steps = workflow.steps.map((s) => nonNull(state.get(s.id), `step ${s.id} was resolved`));
const pending = steps.filter((s) => s.status === "pending").map((s) => s.id);
const isRejected = steps.some((s) => s.status === "rejected");
const outcome: ExecutionState["outcome"] = isRejected
? "rejected"
: pending.length > 0
? "awaiting"
: "approved";
return { steps, pending, outcome };
};
/** Kahn topological sort over the step graph. Assumes a DAG (the model is one). */
const topoOrder = (workflow: ApprovalWorkflow): string[] => {
const indegree = new Map<string, number>();
for (const s of workflow.steps) indegree.set(s.id, 0);
for (const s of workflow.steps) {
for (const n of s.next) indegree.set(n, (indegree.get(n) ?? 0) + 1);
}
const queue = [...indegree].filter(([, d]) => d === 0).map(([id]) => id);
const order: string[] = [];
const byId = new Map(workflow.steps.map((s) => [s.id, s]));
for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
order.push(id);
const nextIds = byId.get(id)?.next ?? [];
for (const n of nextIds) {
const d = (indegree.get(n) ?? 0) - 1;
indegree.set(n, d);
if (d === 0) queue.push(n);
}
}
// Any nodes not emitted (shouldn't happen in a DAG) are appended so they still resolve.
for (const s of workflow.steps) if (!order.includes(s.id)) order.push(s.id);
return order;
};