Skip to content

Commit 17b4c8e

Browse files
committed
fix: verify workflow status after rejected guard updates
1 parent afea0c2 commit 17b4c8e

2 files changed

Lines changed: 45 additions & 13 deletions

File tree

scripts/loops/watchdog.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ function fakeProvider(status = "Draft") {
2828
const attemptedWrites: string[] = [];
2929
let brokenWorkflow: string | undefined;
3030
let startedWorkflow: string | undefined;
31+
let startedOnWrite = false;
32+
let startError: Error | undefined;
3133
const api: Pick<LoopsApi, "request"> = {
3234
async request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
3335
if (path === "api-key") return { teamName: "Cap Software, Inc." } as T;
@@ -40,6 +42,10 @@ function fakeProvider(status = "Draft") {
4042
if (!guard) throw new Error("Unknown guard");
4143
if (method === "POST") {
4244
attemptedWrites.push(target.workflowId);
45+
if (target.workflowId === startedWorkflow) {
46+
startedOnWrite = true;
47+
if (startError) throw startError;
48+
}
4349
if (status === "Sending" || target.workflowId === startedWorkflow)
4450
throw new LoopsApiError(400, path, {
4551
message:
@@ -61,7 +67,10 @@ function fakeProvider(status = "Draft") {
6167
}
6268
return {
6369
name: target.journey.name,
64-
status,
70+
status:
71+
target.workflowId === startedWorkflow && startedOnWrite
72+
? "Sending"
73+
: status,
6574
mailingListId: target.mailingListId,
6675
rootNodeId: "trigger",
6776
nodes: { trigger: { nextNodeIds: [target.guardId] } },
@@ -73,8 +82,9 @@ function fakeProvider(status = "Draft") {
7382
guards,
7483
writes,
7584
attemptedWrites,
76-
startDuringWrite: (id: string) => {
85+
startDuringWrite: (id: string, error?: Error) => {
7786
startedWorkflow = id;
87+
startError = error;
7888
},
7989
fail: (id: string) => {
8090
brokenWorkflow = id;
@@ -102,6 +112,22 @@ describe("independent delivery safety", () => {
102112
expect(results.every((result) => result.action === "held")).toBe(true);
103113
});
104114

115+
test("concurrent activation is detected even when the provider error format changes", async () => {
116+
for (const error of [
117+
new LoopsApiError(400, "nodes/guard", { error: "Workflow is active" }),
118+
new SyntaxError("Unexpected response encoding"),
119+
]) {
120+
const provider = fakeProvider();
121+
provider.startDuringWrite(safetyTargets[0].workflowId, error);
122+
const result = await enforceDeliverySafety(provider.api, {
123+
healthy: false,
124+
apply: true,
125+
});
126+
expect(result[0].action).toBe("manual-pause-required");
127+
expect(provider.attemptedWrites).toHaveLength(4);
128+
}
129+
});
130+
105131
test("healthy operation never rewrites audiences", async () => {
106132
const provider = fakeProvider();
107133
const result = await enforceDeliverySafety(provider.api, {

scripts/loops/watchdog.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { journeys } from "../../emails/flows";
99
import registry from "../../emails/resources.json";
1010
import type { Journey } from "../../emails/types";
11-
import { LoopsApi, LoopsApiError } from "./api";
11+
import { LoopsApi } from "./api";
1212

1313
export const healthUrl = "https://cap.so/api/cron/sync-loops/health";
1414

@@ -109,6 +109,8 @@ export async function enforceDeliverySafety(
109109
assert.equal(identity.teamName, registry.teamName);
110110
const results: { workflow: string; action: string }[] = [];
111111
for (const target of options.targets ?? safetyTargets) {
112+
let sending = false;
113+
let attemptedUpdate = false;
112114
try {
113115
const path = `workflows/${target.workflowId}`;
114116
const workflow = await api.request<{
@@ -119,6 +121,7 @@ export async function enforceDeliverySafety(
119121
nodes: Record<string, { nextNodeIds: string[] }>;
120122
}>(path);
121123
assert.equal(workflow.name, target.journey.name);
124+
sending = workflow.status === "Sending";
122125
assert(
123126
["Draft", "Sending", "Paused", "PausedAndQueueing"].includes(
124127
workflow.status,
@@ -171,14 +174,15 @@ export async function enforceDeliverySafety(
171174
guard.appliesDownstream
172175
)
173176
nextFilter = undefined;
174-
if (nextFilter && workflow.status === "Sending") {
177+
if (nextFilter && sending) {
175178
results.push({
176179
workflow: target.journey.key,
177180
action: "manual-pause-required",
178181
});
179182
continue;
180183
}
181184
if (nextFilter && options.apply) {
185+
attemptedUpdate = true;
182186
await api.request(nodePath, "POST", {
183187
expectedRevisionId: guard.workflowRevisionId,
184188
payload: { audienceFilter: nextFilter, appliesDownstream: true },
@@ -188,15 +192,17 @@ export async function enforceDeliverySafety(
188192
assert.equal(confirmed.appliesDownstream, true);
189193
}
190194
results.push({ workflow: target.journey.key, action });
191-
} catch (error) {
192-
const sending =
193-
error instanceof LoopsApiError &&
194-
error.status === 400 &&
195-
typeof error.details === "object" &&
196-
error.details !== null &&
197-
"message" in error.details &&
198-
error.details.message ===
199-
"This operation is not allowed while the workflow is sending.";
195+
} catch {
196+
if (attemptedUpdate && !sending) {
197+
try {
198+
const latest = await api.request<{ status: string }>(
199+
`workflows/${target.workflowId}`,
200+
);
201+
sending = latest.status === "Sending";
202+
} catch {
203+
sending = false;
204+
}
205+
}
200206
results.push({
201207
workflow: target.journey.key,
202208
action: sending ? "manual-pause-required" : "error",

0 commit comments

Comments
 (0)