@shware/workflow 9.0.2 → 9.1.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 (76) hide show
  1. package/README.md +74 -4
  2. package/dist/cloudflare/bindings.cjs.map +1 -1
  3. package/dist/cloudflare/bindings.d.cts +7 -7
  4. package/dist/cloudflare/bindings.d.mts +7 -7
  5. package/dist/cloudflare/bindings.mjs.map +1 -1
  6. package/dist/dsl/flow.cjs +2 -1
  7. package/dist/dsl/flow.cjs.map +1 -1
  8. package/dist/dsl/flow.d.cts.map +1 -1
  9. package/dist/dsl/flow.d.mts.map +1 -1
  10. package/dist/dsl/flow.mjs +2 -1
  11. package/dist/dsl/flow.mjs.map +1 -1
  12. package/dist/engine/interpreter.cjs +46 -10
  13. package/dist/engine/interpreter.cjs.map +1 -1
  14. package/dist/engine/interpreter.mjs +46 -10
  15. package/dist/engine/interpreter.mjs.map +1 -1
  16. package/dist/engine/ports.d.cts +31 -6
  17. package/dist/engine/ports.d.cts.map +1 -1
  18. package/dist/engine/ports.d.mts +31 -6
  19. package/dist/engine/ports.d.mts.map +1 -1
  20. package/dist/host/ingest.cjs +86 -3
  21. package/dist/host/ingest.cjs.map +1 -1
  22. package/dist/host/ingest.d.cts +30 -3
  23. package/dist/host/ingest.d.cts.map +1 -1
  24. package/dist/host/ingest.d.mts +30 -3
  25. package/dist/host/ingest.d.mts.map +1 -1
  26. package/dist/host/ingest.mjs +85 -4
  27. package/dist/host/ingest.mjs.map +1 -1
  28. package/dist/ir.cjs +2 -1
  29. package/dist/ir.cjs.map +1 -1
  30. package/dist/ir.d.cts +6 -0
  31. package/dist/ir.d.cts.map +1 -1
  32. package/dist/ir.d.mts +6 -0
  33. package/dist/ir.d.mts.map +1 -1
  34. package/dist/ir.mjs +2 -1
  35. package/dist/ir.mjs.map +1 -1
  36. package/dist/reporting/observer.cjs +2 -0
  37. package/dist/reporting/observer.cjs.map +1 -1
  38. package/dist/reporting/observer.mjs +2 -0
  39. package/dist/reporting/observer.mjs.map +1 -1
  40. package/dist/store/index.cjs.map +1 -1
  41. package/dist/store/index.d.cts +28 -0
  42. package/dist/store/index.d.cts.map +1 -1
  43. package/dist/store/index.d.mts +28 -0
  44. package/dist/store/index.d.mts.map +1 -1
  45. package/dist/store/index.mjs.map +1 -1
  46. package/dist/store/postgres-reporting.cjs +9 -0
  47. package/dist/store/postgres-reporting.cjs.map +1 -1
  48. package/dist/store/postgres-reporting.d.cts +2 -0
  49. package/dist/store/postgres-reporting.d.cts.map +1 -1
  50. package/dist/store/postgres-reporting.d.mts +2 -0
  51. package/dist/store/postgres-reporting.d.mts.map +1 -1
  52. package/dist/store/postgres-reporting.mjs +9 -0
  53. package/dist/store/postgres-reporting.mjs.map +1 -1
  54. package/dist/store/postgres.cjs +14 -0
  55. package/dist/store/postgres.cjs.map +1 -1
  56. package/dist/store/postgres.d.cts +3 -1
  57. package/dist/store/postgres.d.cts.map +1 -1
  58. package/dist/store/postgres.d.mts +3 -1
  59. package/dist/store/postgres.d.mts.map +1 -1
  60. package/dist/store/postgres.mjs +14 -0
  61. package/dist/store/postgres.mjs.map +1 -1
  62. package/dist/store/tables.cjs +1 -0
  63. package/dist/store/tables.cjs.map +1 -1
  64. package/dist/store/tables.d.cts.map +1 -1
  65. package/dist/store/tables.d.mts.map +1 -1
  66. package/dist/store/tables.mjs +1 -0
  67. package/dist/store/tables.mjs.map +1 -1
  68. package/dist/templates.cjs.map +1 -1
  69. package/dist/templates.d.cts +20 -0
  70. package/dist/templates.d.cts.map +1 -1
  71. package/dist/templates.d.mts +20 -0
  72. package/dist/templates.d.mts.map +1 -1
  73. package/dist/templates.mjs.map +1 -1
  74. package/dist/transports/cloudflare-email.cjs.map +1 -1
  75. package/dist/transports/cloudflare-email.mjs.map +1 -1
  76. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"interpreter.mjs","names":[],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type {\n FactSource,\n JourneyContext,\n JourneyOutcome,\n OutboundMessage,\n PushDevice,\n} from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations, so a wake-up storm cannot exhaust the platform's step\n * budget. Each loop iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did. Past the cap the wait\n * is not given up: it sleeps to its deadline and checks once more there (see waitLoop) — the\n * answer is still right at the deadline, it just stops being event-driven in between.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\n/** Who a message or report is about now: a journey started by a visitor moves when they sign in. */\nfunction ownerOf(ctx: JourneyContext): Promise<string> {\n return ctx.currentUserId?.() ?? Promise.resolve(ctx.userId);\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: await ownerOf(ctx),\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: await ownerOf(ctx),\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer on both sides: it may attach\n * tracking before, and hears how the transport took the copy after. A send that throws is\n * not reported — the step retries it, and reports when it settles. A report that fails after\n * the send is logged, never thrown: the step would retry and the transport — a webhook, a web\n * push, with no idempotency key of its own — would deliver the copy again.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n // A replayed step whose copy an earlier run already settled: the transport took it once\n if (await observer?.messageSettled?.(message)) return;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const result = await ctx.messages.send(copy);\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n if (result.goalAt !== null) {\n state.convertedAt ??= result.goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\n/**\n * Whether the user opted out of what this step sends. An email or push step checks its topic —\n * an opt-out of the topic on that channel or of the whole channel skips it — and a transactional\n * message (`topic: null`) is never skipped: a receipt or a code goes out whatever the user opted\n * out of. A node compiled before topics existed (no `topic`) checks the channel-wide opt-out\n * only, as it always did. Other channels check the channel.\n */\nasync function suppressed(\n node: Extract<NodeIR, { type: 'message' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n if (node.topic === null) return false;\n return (await ctx.facts.isSuppressed?.(node.channel, node.topic)) === true;\n}\n\n/** The copy's topic, for the channels that have one: what the observer records and the senders may read. */\nfunction topicOf(node: Extract<NodeIR, { type: 'message' }>): { topic?: string | null } {\n return node.channel === 'email' || node.channel === 'push' ? { topic: node.topic ?? null } : {};\n}\n\ntype PushPlan = { props: Record<string, ScalarIR | undefined>; devices: PushDevice[] };\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and the senders drop the repeats —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do<PushPlan | null>(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // Once per step, before any device: the topic's opt-out or the channel's (never a transactional push)\n if (await suppressed(node, ctx)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the topic or the channel is opted out\n if (plan === null) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: await ownerOf(ctx),\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n ...topicOf(node),\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await suppressed(node, ctx)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: await ownerOf(ctx),\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n ...topicOf(node),\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n // The remaining time is checkpointed: a replay must not recompute it from a later clock\n // (the target has passed by then, and a negative sleep is an error on some runtimes)\n const ms = await ctx.step.do(`${node.id}:remaining`, async () => target - Date.now());\n if (ms > 0) await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. An explicit\n // experiment key is global on purpose: two workflows naming the same\n // experiment bucket a user identically. The fallback is namespaced by the\n // workflow, because structural ids ('2', '1.c0.0') repeat across\n // workflows and a bare one would put a user in the same arm of every\n // unrelated split at that position.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? `${ir.id}:${node.id}`}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n args,\n userId: await ownerOf(ctx),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n if (hit.kind === 'goal') {\n state.convertedAt ??= hit.at;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\". Null from here means the deadline passed with\n * nothing found — never a failure, and never a wait given up on early.\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n if (met !== null) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, {\n timeoutMs: remaining,\n deadlineMs: deadline,\n });\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n if (wake === 'timeout') return finalCheck(id, ctx, check);\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n // Check budget exhausted (see MAX_WAIT_CHECKS). Not a timeout: the wait keeps its deadline,\n // only the wake-ups are given up — sleep to the deadline, then check once more there. The\n // subscription rows stay until the caller's unsubscribe; a wake landing meanwhile is dropped\n // by the runtime (nothing is parked on it), which is the point.\n console.warn(\n `[journey] ${ctx.instanceId} ${id}: ${MAX_WAIT_CHECKS} wake-ups without the condition holding — sleeping to the deadline`\n );\n const budgetRemaining = await ctx.step.do(\n `${id}:budget:remaining`,\n async () => deadline - Date.now()\n );\n if (budgetRemaining > 0) await ctx.step.sleep(`${id}:budget:sleep`, budgetRemaining);\n return finalCheck(id, ctx, check);\n}\n\n/** The check once the deadline is reached — one step for all the ways of getting there. */\nasync function finalCheck<R>(\n id: string,\n ctx: JourneyContext,\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n return ctx.step.do(`${id}:final`, async () => check(Date.now()));\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"interpreter.mjs","names":[],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type {\n FactSource,\n JourneyContext,\n JourneyOutcome,\n OutboundMessage,\n PushDevice,\n} from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations, so a wake-up storm cannot exhaust the platform's step\n * budget. Each loop iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did. Past the cap the wait\n * is not given up: it sleeps to its deadline and checks once more there (see waitLoop) — the\n * answer is still right at the deadline, it just stops being event-driven in between.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\n/** Who a message or report is about now: a journey started by a visitor moves when they sign in. */\nfunction ownerOf(ctx: JourneyContext): Promise<string> {\n return ctx.currentUserId?.() ?? Promise.resolve(ctx.userId);\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: await ownerOf(ctx),\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: await ownerOf(ctx),\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer around it: settled? → prepared\n * (the row, the tracking) → claimed (handed to the transport, committed before the send) →\n * send → sent.\n *\n * Delivery is at most once. A send that throws is a definite failure: the claim is released, the\n * step retries, and the copy is reported when it settles. A copy that was claimed but never\n * settled is the uncertain case — the earlier run died between the transport taking it and the\n * report (or the step's checkpoint) — and a replay does not send it again; it logs and moves on.\n * The templates that must arrive opt out of that per template (`redelivery: 'resend'`), and are\n * sent again as the step retries. A report that fails after the send is logged, never thrown:\n * the step would retry, and the copy — now claimed — would be skipped.\n *\n * Why at most once (decided 2026-11-10, after reviewing the alternatives): exactly-once is out of\n * reach — Cloudflare Email Service (the `send_email` binding and the REST endpoint alike) and SES\n * accept no idempotency key or client message id, FCM does not de-duplicate, only APNs and Web\n * Push collapse a repeat on the device. Between the two remaining options, a duplicate marketing\n * email costs spam complaints, unsubscribes and sender reputation, while a lost nudge is invisible\n * to the user and a journey has later steps; this is how Customer.io and Braze behave (the\n * delivery record is the source of truth, no resend on uncertainty). Do not \"fix\" this back to\n * at-least-once: set `redelivery: 'resend'` on the templates that need it instead.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n // A replayed step whose copy an earlier run already settled: the transport took it once\n if (await observer?.messageSettled?.(message)) return;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const claimed = (await observer?.messageClaimed?.(copy)) ?? true;\n if (!claimed && copy.redelivery !== 'resend') {\n console.warn(\n `[workflow] ${copy.idempotencyKey} was handed to the transport by an earlier run and never settled: not sent again (redelivery: 'skip')`\n );\n return;\n }\n let result: Awaited<ReturnType<typeof ctx.messages.send>>;\n try {\n result = await ctx.messages.send(copy);\n } catch (error) {\n // A thrown send is a definite failure: the copy did not go out, so the retry must be free to send it\n try {\n await observer?.messageReleased?.(copy);\n } catch (releaseError) {\n console.error(\n `[workflow] releasing ${copy.idempotencyKey} after a failed send failed`,\n releaseError\n );\n }\n throw error;\n }\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n if (result.goalAt !== null) {\n state.convertedAt ??= result.goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\n/**\n * Whether the user opted out of what this step sends. An email or push step checks its topic —\n * an opt-out of the topic on that channel or of the whole channel skips it — and a transactional\n * message (`topic: null`) is never skipped: a receipt or a code goes out whatever the user opted\n * out of. A node compiled before topics existed (no `topic`) checks the channel-wide opt-out\n * only, as it always did. Other channels check the channel.\n */\nasync function suppressed(\n node: Extract<NodeIR, { type: 'message' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n if (node.topic === null) return false;\n return (await ctx.facts.isSuppressed?.(node.channel, node.topic)) === true;\n}\n\n/** The copy's topic, for the channels that have one: what the observer records and the senders may read. */\nfunction topicOf(node: Extract<NodeIR, { type: 'message' }>): { topic?: string | null } {\n return node.channel === 'email' || node.channel === 'push' ? { topic: node.topic ?? null } : {};\n}\n\n/** The template's `redelivery`, only when it opts into resending: absent is the default, 'skip'. */\nfunction redeliveryOf(node: Extract<NodeIR, { type: 'message' }>): { redelivery?: 'resend' } {\n return node.redelivery === 'resend' ? { redelivery: 'resend' } : {};\n}\n\ntype PushPlan = { props: Record<string, ScalarIR | undefined>; devices: PushDevice[] };\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and skips the ones it reached —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * or claimed are not sent again (see `deliver`; APNs collapses a repeat on the device, FCM does\n * not de-duplicate at all).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do<PushPlan | null>(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // Once per step, before any device: the topic's opt-out or the channel's (never a transactional push)\n if (await suppressed(node, ctx)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the topic or the channel is opted out\n if (plan === null) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: await ownerOf(ctx),\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n ...topicOf(node),\n ...redeliveryOf(node),\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await suppressed(node, ctx)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: await ownerOf(ctx),\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n ...topicOf(node),\n ...redeliveryOf(node),\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n // The remaining time is checkpointed: a replay must not recompute it from a later clock\n // (the target has passed by then, and a negative sleep is an error on some runtimes)\n const ms = await ctx.step.do(`${node.id}:remaining`, async () => target - Date.now());\n if (ms > 0) await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. An explicit\n // experiment key is global on purpose: two workflows naming the same\n // experiment bucket a user identically. The fallback is namespaced by the\n // workflow, because structural ids ('2', '1.c0.0') repeat across\n // workflows and a bare one would put a user in the same arm of every\n // unrelated split at that position.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? `${ir.id}:${node.id}`}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n args,\n userId: await ownerOf(ctx),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n if (hit.kind === 'goal') {\n state.convertedAt ??= hit.at;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\". Null from here means the deadline passed with\n * nothing found — never a failure, and never a wait given up on early.\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n if (met !== null) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, {\n timeoutMs: remaining,\n deadlineMs: deadline,\n });\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n if (wake === 'timeout') return finalCheck(id, ctx, check);\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n // Check budget exhausted (see MAX_WAIT_CHECKS). Not a timeout: the wait keeps its deadline,\n // only the wake-ups are given up — sleep to the deadline, then check once more there. The\n // subscription rows stay until the caller's unsubscribe; a wake landing meanwhile is dropped\n // by the runtime (nothing is parked on it), which is the point.\n console.warn(\n `[journey] ${ctx.instanceId} ${id}: ${MAX_WAIT_CHECKS} wake-ups without the condition holding — sleeping to the deadline`\n );\n const budgetRemaining = await ctx.step.do(\n `${id}:budget:remaining`,\n async () => deadline - Date.now()\n );\n if (budgetRemaining > 0) await ctx.step.sleep(`${id}:budget:sleep`, budgetRemaining);\n return finalCheck(id, ctx, check);\n}\n\n/** The check once the deadline is reached — one step for all the ways of getting there. */\nasync function finalCheck<R>(\n id: string,\n ctx: JourneyContext,\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n return ctx.step.do(`${id}:final`, async () => check(Date.now()));\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":"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"}
@@ -112,12 +112,20 @@ export interface OutboundMessage {
112
112
  /**
113
113
  * `${instanceId}:${nodeId}` (plus `:${token}` for a push copy) — one per copy, stable across
114
114
  * replays and retries. The engine skips a copy the observer reports settled
115
- * (`messageSettled`); past that, de-duplication is the provider's: APNs (collapse id) and Web
116
- * Push (`Topic`) collapse on it, SES and the Cloudflare REST API cannot, so a crash between the
117
- * provider taking a copy and its report still sends it twice there. It is also the copy's
115
+ * (`messageSettled`) or claimed by an earlier run (`messageClaimed`: handed to the transport,
116
+ * outcome unknown — see `redelivery`). The providers do not de-duplicate on it: APNs (collapse
117
+ * id) and Web Push (`Topic`) collapse a repeat on the device, SES and Cloudflare Email Service
118
+ * (binding and REST API alike) accept no idempotency key at all. It is also the copy's
118
119
  * identity for tracking: a push carries it in its payload, so the app can report the tap.
119
120
  */
120
121
  idempotencyKey: string;
122
+ /**
123
+ * What a replayed send step does with a copy an earlier run claimed but never settled (the
124
+ * transport may or may not have taken it): `'skip'` (the default, and absent) treats it as
125
+ * delivered and skips it — at-most-once; `'resend'` sends it again — at-least-once. Set from
126
+ * the template's `redelivery` option.
127
+ */
128
+ redelivery?: 'skip' | 'resend' | undefined;
121
129
  /** What the host's observer asked the sender to add for measuring this copy (see `MessageTracking`). */
122
130
  tracking?: MessageTracking | undefined;
123
131
  }
@@ -175,6 +183,10 @@ export interface MessageSender {
175
183
  * host's — the engine only reports. Every method is optional and must be idempotent: a step
176
184
  * that fails after its report ran is retried and reports again (keyed by the instance and node,
177
185
  * or the copy's idempotency key).
186
+ *
187
+ * Three of them decide whether a copy reaches the transport (`messageSettled`, `messageClaimed`,
188
+ * `messageReleased`): an observer that implements none of them leaves the engine with the step
189
+ * alone, and a step replayed after a lost checkpoint sends again. Implement all three or none.
178
190
  */
179
191
  export interface JourneyObserver {
180
192
  /** The instance entered, before its entry guards: `at` is the trigger's time, the entry anchor. */
@@ -196,13 +208,26 @@ export interface JourneyObserver {
196
208
  * Whether this copy (its idempotency key) was already settled — sent, or settled undelivered —
197
209
  * by an earlier run of the same step. The send and its report sit inside one durable step, so
198
210
  * an instance that crashes after both but before the step's checkpoint replays the step; a
199
- * settled copy is then skipped instead of reaching the transport a second time. (A crash
200
- * between the transport taking it and the report is still a second send: only a provider that
201
- * de-duplicates on the key closes that.)
211
+ * settled copy is then skipped instead of reaching the transport a second time. The window
212
+ * between the transport taking the copy and the settle is `messageClaimed`'s.
202
213
  */
203
214
  messageSettled?(message: OutboundMessage): Promise<boolean>;
204
215
  /** A copy is about to go out; the tracking returned travels with it to the sender. */
205
216
  messagePrepared?(message: OutboundMessage): Promise<MessageTracking | undefined>;
217
+ /**
218
+ * The copy is being handed to the transport: record the claim, committed before the send, and
219
+ * answer whether this run made it (true) or an earlier run of the same step already held it
220
+ * (false). A claim without a settle means the earlier run died between the transport and its
221
+ * report, and the copy may have gone out: the engine does not send it again unless the
222
+ * template says `redelivery: 'resend'` (see `OutboundMessage.redelivery`). Must be atomic —
223
+ * two runs of the step cannot both be told true.
224
+ */
225
+ messageClaimed?(message: OutboundMessage): Promise<boolean>;
226
+ /**
227
+ * The transport threw, so the copy did not go out: release the claim, and the step's retry
228
+ * sends it. Called only after `messageClaimed` answered true in the same run.
229
+ */
230
+ messageReleased?(message: OutboundMessage): Promise<void>;
206
231
  /** The transport took the copy (`delivered: true`), or settled it undelivered; a thrown send is not reported. */
207
232
  messageSent?(message: OutboundMessage, result: {
208
233
  delivered: true;
@@ -1 +1 @@
1
- {"version":3,"file":"ports.d.cts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;;;;EAMnD,MAAM,cAAc,aAAa;;;;;;;;;;;;;;;EAgBjC,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;;;;;;;;EAa3B,YACE,cACA;IAAQ;IAAmB;MAC1B;;;iBAIY;EACf;EACA;;;iBAIe;;;;;;;;;EASf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;IAAa;MACjE;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;;;;;EAMvB,cAAc,SAAS,WAAW,iBAAiB;;iBAGpC;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;EAET;;;;;;;;;EASA;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;;EAMf;;;;;EAKA,QAAQ,aAAa,SAAS;;EAE9B;;;;;;EAMA;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;;;;;;;;EASJ,gBAAgB,SAAS,kBAAkB;;EAE3C,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;;EAEA,uBAAuB;EACvB,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
1
+ {"version":3,"file":"ports.d.cts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;;;;EAMnD,MAAM,cAAc,aAAa;;;;;;;;;;;;;;;EAgBjC,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;;;;;;;;EAa3B,YACE,cACA;IAAQ;IAAmB;MAC1B;;;iBAIY;EACf;EACA;;;iBAIe;;;;;;;;;EASf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;IAAa;MACjE;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;;;;;EAMvB,cAAc,SAAS,WAAW,iBAAiB;;iBAGpC;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;EAET;;;;;;;;;;EAUA;;;;;;;EAOA;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;;EAMf;;;;;EAKA,QAAQ,aAAa,SAAS;;EAE9B;;;;;;EAMA;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;;;;;iBAchC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;;;;;;;EAQJ,gBAAgB,SAAS,kBAAkB;;EAE3C,iBAAiB,SAAS,kBAAkB,QAAQ;;;;;;;;;EASpD,gBAAgB,SAAS,kBAAkB;;;;;EAK3C,iBAAiB,SAAS,kBAAkB;;EAE5C,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;;EAEA,uBAAuB;EACvB,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
@@ -112,12 +112,20 @@ export interface OutboundMessage {
112
112
  /**
113
113
  * `${instanceId}:${nodeId}` (plus `:${token}` for a push copy) — one per copy, stable across
114
114
  * replays and retries. The engine skips a copy the observer reports settled
115
- * (`messageSettled`); past that, de-duplication is the provider's: APNs (collapse id) and Web
116
- * Push (`Topic`) collapse on it, SES and the Cloudflare REST API cannot, so a crash between the
117
- * provider taking a copy and its report still sends it twice there. It is also the copy's
115
+ * (`messageSettled`) or claimed by an earlier run (`messageClaimed`: handed to the transport,
116
+ * outcome unknown — see `redelivery`). The providers do not de-duplicate on it: APNs (collapse
117
+ * id) and Web Push (`Topic`) collapse a repeat on the device, SES and Cloudflare Email Service
118
+ * (binding and REST API alike) accept no idempotency key at all. It is also the copy's
118
119
  * identity for tracking: a push carries it in its payload, so the app can report the tap.
119
120
  */
120
121
  idempotencyKey: string;
122
+ /**
123
+ * What a replayed send step does with a copy an earlier run claimed but never settled (the
124
+ * transport may or may not have taken it): `'skip'` (the default, and absent) treats it as
125
+ * delivered and skips it — at-most-once; `'resend'` sends it again — at-least-once. Set from
126
+ * the template's `redelivery` option.
127
+ */
128
+ redelivery?: 'skip' | 'resend' | undefined;
121
129
  /** What the host's observer asked the sender to add for measuring this copy (see `MessageTracking`). */
122
130
  tracking?: MessageTracking | undefined;
123
131
  }
@@ -175,6 +183,10 @@ export interface MessageSender {
175
183
  * host's — the engine only reports. Every method is optional and must be idempotent: a step
176
184
  * that fails after its report ran is retried and reports again (keyed by the instance and node,
177
185
  * or the copy's idempotency key).
186
+ *
187
+ * Three of them decide whether a copy reaches the transport (`messageSettled`, `messageClaimed`,
188
+ * `messageReleased`): an observer that implements none of them leaves the engine with the step
189
+ * alone, and a step replayed after a lost checkpoint sends again. Implement all three or none.
178
190
  */
179
191
  export interface JourneyObserver {
180
192
  /** The instance entered, before its entry guards: `at` is the trigger's time, the entry anchor. */
@@ -196,13 +208,26 @@ export interface JourneyObserver {
196
208
  * Whether this copy (its idempotency key) was already settled — sent, or settled undelivered —
197
209
  * by an earlier run of the same step. The send and its report sit inside one durable step, so
198
210
  * an instance that crashes after both but before the step's checkpoint replays the step; a
199
- * settled copy is then skipped instead of reaching the transport a second time. (A crash
200
- * between the transport taking it and the report is still a second send: only a provider that
201
- * de-duplicates on the key closes that.)
211
+ * settled copy is then skipped instead of reaching the transport a second time. The window
212
+ * between the transport taking the copy and the settle is `messageClaimed`'s.
202
213
  */
203
214
  messageSettled?(message: OutboundMessage): Promise<boolean>;
204
215
  /** A copy is about to go out; the tracking returned travels with it to the sender. */
205
216
  messagePrepared?(message: OutboundMessage): Promise<MessageTracking | undefined>;
217
+ /**
218
+ * The copy is being handed to the transport: record the claim, committed before the send, and
219
+ * answer whether this run made it (true) or an earlier run of the same step already held it
220
+ * (false). A claim without a settle means the earlier run died between the transport and its
221
+ * report, and the copy may have gone out: the engine does not send it again unless the
222
+ * template says `redelivery: 'resend'` (see `OutboundMessage.redelivery`). Must be atomic —
223
+ * two runs of the step cannot both be told true.
224
+ */
225
+ messageClaimed?(message: OutboundMessage): Promise<boolean>;
226
+ /**
227
+ * The transport threw, so the copy did not go out: release the claim, and the step's retry
228
+ * sends it. Called only after `messageClaimed` answered true in the same run.
229
+ */
230
+ messageReleased?(message: OutboundMessage): Promise<void>;
206
231
  /** The transport took the copy (`delivered: true`), or settled it undelivered; a thrown send is not reported. */
207
232
  messageSent?(message: OutboundMessage, result: {
208
233
  delivered: true;
@@ -1 +1 @@
1
- {"version":3,"file":"ports.d.mts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;;;;EAMnD,MAAM,cAAc,aAAa;;;;;;;;;;;;;;;EAgBjC,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;;;;;;;;EAa3B,YACE,cACA;IAAQ;IAAmB;MAC1B;;;iBAIY;EACf;EACA;;;iBAIe;;;;;;;;;EASf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;IAAa;MACjE;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;;;;;EAMvB,cAAc,SAAS,WAAW,iBAAiB;;iBAGpC;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;EAET;;;;;;;;;EASA;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;;EAMf;;;;;EAKA,QAAQ,aAAa,SAAS;;EAE9B;;;;;;EAMA;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;;;;;;;;EASJ,gBAAgB,SAAS,kBAAkB;;EAE3C,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;;EAEA,uBAAuB;EACvB,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
1
+ {"version":3,"file":"ports.d.mts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;;;;EAMnD,MAAM,cAAc,aAAa;;;;;;;;;;;;;;;EAgBjC,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;;;;;;;;EAa3B,YACE,cACA;IAAQ;IAAmB;MAC1B;;;iBAIY;EACf;EACA;;;iBAIe;;;;;;;;;EASf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;IAAa;MACjE;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;;;;;EAMvB,cAAc,SAAS,WAAW,iBAAiB;;iBAGpC;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;EAET;;;;;;;;;;EAUA;;;;;;;EAOA;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;;EAMf;;;;;EAKA,QAAQ,aAAa,SAAS;;EAE9B;;;;;;EAMA;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;;;;;iBAchC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;;;;;;;EAQJ,gBAAgB,SAAS,kBAAkB;;EAE3C,iBAAiB,SAAS,kBAAkB,QAAQ;;;;;;;;;EASpD,gBAAgB,SAAS,kBAAkB;;;;;EAK3C,iBAAiB,SAAS,kBAAkB;;EAE5C,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;;EAEA,uBAAuB;EACvB,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
@@ -119,6 +119,24 @@ async function refreshSegmentTriggers(env, store, userId, ts) {
119
119
  * and act on each membership transition: entering records the membership and starts the
120
120
  * segment's routed workflows, leaving drops it. A segment trigger therefore starts journeys for
121
121
  * users who enter after it is deployed; a user already a member is not entered again.
122
+ *
123
+ * Why the membership writes and the starts are separate store calls, and why that is safe:
124
+ * - Each segment's transition is one store operation. `addSegmentMember` writes the membership
125
+ * and the starts it owes (`segment_start`) in one locked transaction; `removeSegmentMember`
126
+ * drops both in one transaction. No failure leaves a segment half-entered or half-left.
127
+ * - Starts come only from `pendingSegmentStarts`, which exists only once that transaction has
128
+ * committed, so a journey is never started for a membership that was not recorded. A start that
129
+ * fails stays pending and is retried by the next evaluation (the sweep, the user's next event);
130
+ * it is crossed off only once an instance is known to exist (`entryStarted`).
131
+ * - A retried or concurrent start cannot run a journey twice: `startJourney` goes through the
132
+ * entry ledger, keyed by (workflow, user) under the once policy, and the membership insert is
133
+ * `ON CONFLICT DO NOTHING`.
134
+ * - Segments are independent memberships. A failure on one leaves the others where they were;
135
+ * every evaluation recomputes each from facts and the stored membership, so the next one
136
+ * converges rather than building on a partial run.
137
+ * Folding every segment's writes into one transaction would add nothing: the starts have to happen
138
+ * after the commit anyway (a workflow instance cannot join a database transaction), which is what
139
+ * the `segment_start` outbox already provides.
122
140
  */
123
141
  async function refreshSegments(env, store, userId, ts, routes, segments = [], definitions = /* @__PURE__ */ new Map()) {
124
142
  if (require_store_identity.isVisitorSubject(userId)) return [];
@@ -199,6 +217,63 @@ async function failLostStarts(env, store, now) {
199
217
  }
200
218
  return failed;
201
219
  }
220
+ /**
221
+ * How long after its admission an entry is left out of the lost-instance sample. No age proves
222
+ * anything about an admitted instance — it can end a moment after admitting itself, and a long
223
+ * wait runs for weeks — so this is not a judgment, only a filter: the freshest entries are the
224
+ * ones most likely to still be running, and the ones a platform status may not have settled
225
+ * for yet. Correctness rests on the lookup and the conditional update, not on the window.
226
+ */
227
+ const LOST_INSTANCE_AFTER_MS = 9e5;
228
+ /**
229
+ * Admitted entries one maintenance run looks up on the platform: a random sample (see
230
+ * `JourneyStore.admittedEntries`), so every entry is reached eventually however many are
231
+ * running, and the sweep's platform calls stay bounded.
232
+ */
233
+ const LOST_INSTANCE_BATCH = 50;
234
+ /**
235
+ * Instances that ended on the platform without finalizing their entry: terminated from outside
236
+ * (the dashboard, the API), errored for good because a finalize step kept failing, or no longer
237
+ * known to the runtime at all. The entry stays `running`, and the once-policy would bar the user
238
+ * from the workflow for good. Only admitted entries are looked at — an unadmitted one is
239
+ * `failLostStarts`' — and only a terminal status, or not found, ends one: an admitted instance
240
+ * did reach the runtime, so not found now means it finished and was evicted (as `stopInstance`
241
+ * reads it). A lookup that fails otherwise proves nothing and leaves the entry alone. The update
242
+ * is conditional on the entry still running under the instance id, so a run that finalized
243
+ * between the read and the lookup keeps its own status.
244
+ *
245
+ * `complete` ends in `failed` too: the run's finalize step is what records the real outcome, and
246
+ * an instance complete with its entry still running went without it, so what the journey would
247
+ * have recorded is unknown. Platform-neutral: on AWS the adapter's own maintenance reconciles
248
+ * these too (both writes are idempotent), and its `status` reads running through a stop's
249
+ * drain, so nothing is failed here that the adapter would still wait for.
250
+ */
251
+ async function failLostInstances(env, store, now) {
252
+ const failed = [];
253
+ for (const entry of await store.admittedEntries(now - LOST_INSTANCE_AFTER_MS, LOST_INSTANCE_BATCH)) {
254
+ let status;
255
+ try {
256
+ const reported = await (await env.JOURNEY.get(entry.instanceId)).status?.();
257
+ if (reported === void 0 || !STOPPED_STATUSES.has(reported.status)) continue;
258
+ status = reported.status;
259
+ } catch (error) {
260
+ if (!(error instanceof require_host_platform.InstanceNotFoundError)) continue;
261
+ status = "not found";
262
+ }
263
+ if (!await store.failAdmittedEntry(entry.instanceId)) continue;
264
+ failed.push(entry.instanceId);
265
+ await store.unsubscribe(entry.instanceId);
266
+ await store.reporting.instanceFinished({
267
+ workflow: entry.workflow,
268
+ instanceId: entry.instanceId,
269
+ userId: entry.userId,
270
+ status: "failed",
271
+ at: now
272
+ });
273
+ console.error(`[workflow] ${entry.instanceId} (${entry.workflow}) ended on the platform (${status}) without finishing: marked failed, the next trigger re-enters`);
274
+ }
275
+ return failed;
276
+ }
202
277
  /** How long a visitor who never signs in is kept after they were last heard from. */
203
278
  const VISITOR_RETENTION_MS = 7776e6;
204
279
  /** Visitors one sweep prunes: the next sweep goes on. */
@@ -207,9 +282,10 @@ const VISITOR_PRUNE_BATCH = 100;
207
282
  const SWEEP_PAGE_SIZE = 500;
208
283
  /**
209
284
  * What the sweep does before it looks at any user: the runtime's own upkeep (AWS: unconfirmed
210
- * starts, executions that ended unseen) and the erasures left unfinished (an instance would not
211
- * stop). A failure of either is logged, never fatal: the pages still run. Users whose erasure is
212
- * still pending afterwards are skipped by every page (`sweepSegmentsPaged`).
285
+ * starts, executions that ended unseen), the entries whose start or instance was lost, and the
286
+ * erasures left unfinished (an instance would not stop). A failure of any is logged, never
287
+ * fatal: the pages still run. Users whose erasure is still pending afterwards are skipped by
288
+ * every page (`sweepSegmentsPaged`).
213
289
  */
214
290
  async function sweepMaintenance(env, store) {
215
291
  try {
@@ -222,6 +298,11 @@ async function sweepMaintenance(env, store) {
222
298
  } catch (error) {
223
299
  console.error("[workflow] reclaiming lost starts failed", error);
224
300
  }
301
+ try {
302
+ await failLostInstances(env, store, Date.now());
303
+ } catch (error) {
304
+ console.error("[workflow] reclaiming lost instances failed", error);
305
+ }
225
306
  try {
226
307
  await store.pruneVisitors(Date.now() - VISITOR_RETENTION_MS, VISITOR_PRUNE_BATCH);
227
308
  } catch (error) {
@@ -803,11 +884,13 @@ async function setSubscriptionsByMessage(env, store, messageId, patch, topics, n
803
884
  };
804
885
  }
805
886
  exports.BundleIntegrityError = BundleIntegrityError;
887
+ exports.LOST_INSTANCE_AFTER_MS = LOST_INSTANCE_AFTER_MS;
806
888
  exports.LOST_START_AFTER_MS = LOST_START_AFTER_MS;
807
889
  exports.SUBSCRIPTION_CHANNELS = SUBSCRIPTION_CHANNELS;
808
890
  exports.SWEEP_PAGE_SIZE = SWEEP_PAGE_SIZE;
809
891
  exports.VISITOR_RETENTION_MS = VISITOR_RETENTION_MS;
810
892
  exports.deployBundle = deployBundle;
893
+ exports.failLostInstances = failLostInstances;
811
894
  exports.failLostStarts = failLostStarts;
812
895
  exports.forgetUser = forgetUser;
813
896
  exports.getSubscriptions = getSubscriptions;