@aglyn/plugins-workflows 1.0.0-beta.178 → 1.0.0-beta.180
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.
- package/package.json +12 -11
- package/src/lib/components/automation-trigger-conditions.component.d.ts +43 -0
- package/src/lib/components/automation-trigger-conditions.component.js +189 -0
- package/src/lib/components/automation-trigger-conditions.component.js.map +1 -0
- package/src/lib/components/host-actions-card.component.js +27 -176
- package/src/lib/components/host-actions-card.component.js.map +1 -1
- package/src/lib/components/org-automation-editor.component.d.ts +51 -0
- package/src/lib/components/org-automation-editor.component.js +310 -0
- package/src/lib/components/org-automation-editor.component.js.map +1 -0
- package/src/lib/components/org-automations-card.component.d.ts +28 -0
- package/src/lib/components/org-automations-card.component.js +373 -0
- package/src/lib/components/org-automations-card.component.js.map +1 -0
- package/src/lib/components/org-site-automation-list.component.d.ts +43 -0
- package/src/lib/components/org-site-automation-list.component.js +289 -0
- package/src/lib/components/org-site-automation-list.component.js.map +1 -0
- package/src/lib/components/site-org-automations-panel.component.d.ts +22 -0
- package/src/lib/components/site-org-automations-panel.component.js +201 -0
- package/src/lib/components/site-org-automations-panel.component.js.map +1 -0
- package/src/lib/components/use-org-automation-step-pickers.d.ts +20 -0
- package/src/lib/components/use-org-automation-step-pickers.js +108 -0
- package/src/lib/components/use-org-automation-step-pickers.js.map +1 -0
- package/src/lib/components/use-org-automations-api.d.ts +16 -0
- package/src/lib/components/use-org-automations-api.js +55 -0
- package/src/lib/components/use-org-automations-api.js.map +1 -0
- package/src/lib/components/workflows-console-page.js +63 -1
- package/src/lib/components/workflows-console-page.js.map +1 -1
- package/src/lib/components/workflows-console-sections.d.ts +19 -1
- package/src/lib/components/workflows-console-sections.js +37 -2
- package/src/lib/components/workflows-console-sections.js.map +1 -1
- package/src/lib/components/workflows-org-mount.d.ts +52 -0
- package/src/lib/components/workflows-org-mount.js +49 -0
- package/src/lib/components/workflows-org-mount.js.map +1 -0
- package/src/lib/engine/flow-enrollments.d.ts +26 -10
- package/src/lib/engine/flow-enrollments.js +14 -7
- package/src/lib/engine/flow-enrollments.js.map +1 -1
- package/src/lib/engine/run-event-actions.d.ts +57 -4
- package/src/lib/engine/run-event-actions.js +119 -19
- package/src/lib/engine/run-event-actions.js.map +1 -1
- package/src/lib/engine/run-org-automations.d.ts +75 -0
- package/src/lib/engine/run-org-automations.js +154 -0
- package/src/lib/engine/run-org-automations.js.map +1 -0
- package/src/lib/model/org-automations.d.ts +232 -0
- package/src/lib/model/org-automations.js +423 -0
- package/src/lib/model/org-automations.js.map +1 -0
- package/src/lib/plugin.js +26 -1
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/org-automations-routes.d.ts +88 -0
- package/src/lib/server/org-automations-routes.js +474 -0
- package/src/lib/server/org-automations-routes.js.map +1 -0
- package/src/lib/server-console.d.ts +2 -1
- package/src/lib/server-console.js +12 -2
- package/src/lib/server-console.js.map +1 -1
|
@@ -65,8 +65,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
65
65
|
*/ import { personKey } from "@aglyn/aglyn/server";
|
|
66
66
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
67
67
|
/**
|
|
68
|
-
* `hosts/{hostId}/flowEnrollments/{actionId__personKey}` for an action,
|
|
69
|
-
* `{workflow-<workflowId>__personKey}` for a workflow
|
|
68
|
+
* `hosts/{hostId}/flowEnrollments/{actionId__personKey}` for an action,
|
|
69
|
+
* `{workflow-<workflowId>__personKey}` for a workflow, and
|
|
70
|
+
* `{org-<automationId>__personKey}` for an org automation waiting on the site
|
|
71
|
+
* it ran on.
|
|
70
72
|
*/ export const FLOW_ENROLLMENTS_SUBCOLLECTION = 'flowEnrollments';
|
|
71
73
|
/**
|
|
72
74
|
* Documents one sweep may READ across every site.
|
|
@@ -105,9 +107,12 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
|
105
107
|
* enrollments would key on something narrower than the person (the cart, the
|
|
106
108
|
* order), which is a change to this function and to nothing else.
|
|
107
109
|
*/ export function flowEnrollmentId(actionId, key, automation) {
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
110
|
+
// Each kind's id is kept apart from the others', so no two kinds can ever
|
|
111
|
+
// share a row whatever ids their collections hand out — an org automation's
|
|
112
|
+
// id comes from a different collection than the site's own action ids.
|
|
113
|
+
if (automation === 'workflow') return `workflow-${actionId}__${key}`;
|
|
114
|
+
if (automation === 'org') return `org-${actionId}__${key}`;
|
|
115
|
+
return `${actionId}__${key}`;
|
|
111
116
|
}
|
|
112
117
|
function enrollmentsRef(hostId, firestore) {
|
|
113
118
|
return (firestore != null ? firestore : firebaseAdmin.app().firestore()).collection('hosts').doc(hostId).collection(FLOW_ENROLLMENTS_SUBCOLLECTION);
|
|
@@ -156,8 +161,10 @@ function enrollmentsRef(hostId, firestore) {
|
|
|
156
161
|
}
|
|
157
162
|
const enrollment = _extends({
|
|
158
163
|
hostId: options.hostId
|
|
159
|
-
}, options.automation
|
|
160
|
-
automation:
|
|
164
|
+
}, options.automation ? {
|
|
165
|
+
automation: options.automation
|
|
166
|
+
} : {}, options.automation === 'org' && options.orgId ? {
|
|
167
|
+
orgId: options.orgId
|
|
161
168
|
} : {}, {
|
|
162
169
|
actionId: options.actionId,
|
|
163
170
|
actionName: String((_options_action_name = options.action.name) != null ? _options_action_name : ''),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/workflows/src/lib/engine/flow-enrollments.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHERE A PERSON WAITS, between one step of a flow and the next.\n *\n * An action used to be trigger → immediate steps, start to finish inside the\n * request that emitted the event. A `wait` step ends that: the run stops, and\n * something else has to pick it up minutes or weeks later. This module is the\n * something else's memory.\n *\n * ## The scheduling model, and why it survives a deploy\n *\n * There is no timer anywhere. A wait is a ROW — `resumeAtMs` on a document in\n * `hosts/{hostId}/flowEnrollments` — and the resume is a query for rows whose\n * time has come, run by the platform job beat that already exists. Nothing is\n * held in a process, so nothing is lost when the process ends: a deploy, a\n * restart, a cold start and a region failover all leave the row exactly where\n * it was, and the next beat finds it. The two worked examples in this repo\n * take the same shape for the same reason — `campaign-process-scheduled.ts`\n * claims `status: 'scheduled'` campaigns whose `sendAtMs` has passed, and the\n * abandoned-checkout scan re-reads any checkout it did not stamp.\n *\n * A beat that dies mid-resume is the case worth stating. The claim below flips\n * `waiting` → `running` in a transaction, so a second beat cannot pick up the\n * same enrollment; and a `running` row whose beat never came back is re-armed\n * by {@link FLOW_CLAIM_STALE_MS}, which is the difference between \"somebody is\n * working on this\" and \"somebody died holding this\".\n *\n * ## What it costs, which is not \"every enrollment on every beat\"\n *\n * The sweep reads DUE rows and only due rows: the query carries\n * `resumeAtMs <= now`, so a site with ten thousand people waiting three days\n * costs nothing on the beats before those three days are up. That is the whole\n * difference between this and the naive design, which re-reads every enrolled\n * person on every beat to ask whether their time has come.\n *\n * On top of that, {@link FLOW_RESUME_SCAN_BUDGET} bounds one beat's work and a\n * cursor resumes it — the shape `dynamic-list-materialize.ts` uses. The budget\n * matters even though the query is already narrow, because a due row that\n * cannot be acted on (a locked host) stays due: without paging past it, one\n * wall of locked rows at the head of the queue would starve every enrollment\n * behind it for as long as the lock lasted.\n *\n * ## ⛔ A BUDGET NEVER DROPS AN ENROLLMENT\n *\n * Running out of budget stops the sweep where it is. It never marks an\n * enrollment done, never advances its step, and never deletes it — the row\n * stays `waiting` and overdue, and the next beat starts again from the front\n * of the queue, which is where the oldest overdue work is. The same rule the\n * list materializer states: a bound on WORK is not a bound on PEOPLE.\n */\n\nimport { personKey } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport type { PluginJobHostGate } from '@aglyn/aglyn/server'\nimport type { WorkflowStep } from './workflow-steps'\n\n/**\n * `hosts/{hostId}/flowEnrollments/{actionId__personKey}` for an action, and\n * `{workflow-<workflowId>__personKey}` for a workflow.\n */\nexport const FLOW_ENROLLMENTS_SUBCOLLECTION = 'flowEnrollments'\n\n/**\n * Documents one sweep may READ across every site.\n *\n * A bound on work, not on membership — see the note above. Sized well past a\n * normal beat's due queue so the ordinary case never pages at all, and far\n * short of what one API invocation should spend.\n */\nexport const FLOW_RESUME_SCAN_BUDGET = 500\n\n/** Due enrollments read per page. */\nconst PAGE_SIZE = 50\n\n/**\n * How long a claim may go unfinished before another beat may take it.\n *\n * A `running` row is one a beat is part-way through. If that beat's process\n * ends — a deploy mid-resume, a function timeout, an unhandled throw that\n * escaped the isolation — nothing ever clears the flag, and without this the\n * enrollment would be stranded `running` for ever: not waiting, so no sweep\n * finds it; not done, so nobody is told.\n *\n * Ten minutes is far longer than a resume takes (a handful of Firestore reads\n * and at most one email) and far shorter than the shortest wait anybody\n * authors, so the only thing it can re-arm is a claim nobody is holding.\n *\n * The cost of getting it wrong is a step running twice, which is why the\n * window is generous rather than tight.\n */\nexport const FLOW_CLAIM_STALE_MS = 10 * 60_000\n\nexport type FlowEnrollmentStatus = 'waiting' | 'running'\n\n/**\n * Why an enrollment ended. Recorded on the run history, never on the\n * enrollment — the row is deleted when the flow ends, so the activity\n * collection is where \"what happened to this person\" lives.\n */\nexport type FlowEnrollmentEnding =\n /** Every remaining step ran. */\n | 'completed'\n /** An `exitFlow` step, or a guard that ended the run. */\n | 'exited'\n /** The action was deleted, disabled, or lost its entitlement. */\n | 'flow-stopped'\n\nexport interface FlowEnrollment {\n hostId: string\n /**\n * What is waiting: `workflow` for a workflow's run, absent for an action's\n * — which is every enrollment written before a workflow could wait, so an\n * absent field is read as an action for as long as those rows exist.\n */\n automation?: 'workflow'\n /** The automation's document id: the action's, or the workflow's. */\n actionId: string\n actionName: string\n status: FlowEnrollmentStatus\n /** When this flow continues. ALWAYS set — the sweep orders on it. */\n resumeAtMs: number\n /** Index into {@link steps} of the step that runs on resume. */\n nextStepIndex: number\n /**\n * THE FLOW AS IT WAS WHEN THIS PERSON ENTERED IT.\n *\n * Not a reference to the action's current steps, and this is the whole\n * answer to \"what happens to a flow somebody is already waiting inside\".\n * `nextStepIndex` is a position in a LIST, and an author who reorders,\n * inserts or deletes a step moves every position after it — so resuming a\n * three-day wait against the edited list would deliver whatever now happens\n * to sit at index 4. That is not a changed flow, it is a scrambled one, and\n * the person it happens to is the one who cannot see it.\n *\n * So an edit applies to everyone who enrolls AFTER it, and nobody who is\n * already inside. The trade is that a merchant who fixes a typo in step 3\n * does not fix it for the people mid-wait; the alternative trade is sending\n * them a step from a different flow, which is worse and silent.\n *\n * Bounded by `ACTION_MAX_STEPS` for an action, so the snapshot is ten\n * small objects, and by `WORKFLOW_MAX_STEPS` for a workflow, whose snapshot\n * holds its function calls beside its Actions steps.\n */\n steps: WorkflowStep[]\n /** The event a `waitForEvent` is watching for; absent for a plain wait. */\n awaitingEvent?: string | null\n /** `sha256` of the person's address — the wake lookup key. */\n personKey: string\n email: string\n /** The trigger payload, carried forward so later steps see the same scope. */\n payload: Record<string, unknown>\n /** The event that started the flow, for the run-history line. */\n event: string\n enrolledAtMs: number\n updatedAtMs: number\n /** How many times this enrollment has been picked up. */\n resumes: number\n /** Set while `running`; how {@link FLOW_CLAIM_STALE_MS} is measured. */\n claimedAtMs?: number\n}\n\n/**\n * ONE LIVE ENROLLMENT PER PERSON PER FLOW, by construction.\n *\n * The document id is derived rather than generated, so a second concurrent\n * enrollment is not a race to detect — it is a write to a document that\n * already exists. A shopper who abandons three carts in an hour gets one\n * recovery sequence, not three overlapping ones, and the guarantee holds\n * across processes because it is Firestore's and not a lock of ours.\n *\n * DELIBERATE, and reversible: a flow that genuinely wants concurrent\n * enrollments would key on something narrower than the person (the cart, the\n * order), which is a change to this function and to nothing else.\n */\nexport function flowEnrollmentId(\n actionId: string,\n key: string,\n automation?: 'workflow',\n): string {\n // A workflow's id is kept apart from an action's, so the two kinds can\n // never share a row whatever ids their collections hand out.\n return automation === 'workflow'\n ? `workflow-${actionId}__${key}`\n : `${actionId}__${key}`\n}\n\nfunction enrollmentsRef(\n hostId: string,\n firestore?: any,\n): FirebaseFirestore.CollectionReference {\n return (firestore ?? firebaseAdmin.app().firestore())\n .collection('hosts')\n .doc(hostId)\n .collection(FLOW_ENROLLMENTS_SUBCOLLECTION)\n}\n\nexport interface EnrollInFlowOptions {\n hostId: string\n /** `workflow` for a workflow's run; absent for an action's. */\n automation?: 'workflow'\n /** The automation's document id. */\n actionId: string\n action: { name?: string; steps?: readonly WorkflowStep[] }\n /** The address the flow is about. A flow with no person cannot wait. */\n email: string\n event: string\n payload: Record<string, unknown>\n /** Index of the step that runs when the wait ends. */\n nextStepIndex: number\n resumeAtMs: number\n awaitingEvent?: string | null\n nowMs?: number\n firestore?: any\n}\n\nexport type EnrollInFlowResult =\n | { enrolled: true; id: string }\n | { enrolled: false; reason: 'no-person' | 'already-waiting' }\n\n/**\n * Suspends a run, durably.\n *\n * Refuses rather than improvises when there is nobody to wait for. A flow\n * that waits is a flow that continues later for a PERSON — it is how the\n * enrollment is keyed, how a `waitForEvent` is woken, and who the next step's\n * email is addressed to. An anonymous wait would have no dedupe key, so a\n * page-view trigger on a busy site would mint an enrollment per visit; and\n * nothing downstream could use it. Saying so is better than silently\n * enrolling nobody.\n */\nexport async function enrollInFlow(\n options: EnrollInFlowOptions,\n): Promise<EnrollInFlowResult> {\n const key = personKey(options.email)\n if (!key) return { enrolled: false, reason: 'no-person' }\n const nowMs = options.nowMs ?? Date.now()\n const ref = enrollmentsRef(options.hostId, options.firestore).doc(\n flowEnrollmentId(options.actionId, key, options.automation),\n )\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n return await firestore.runTransaction(\n async (transaction: FirebaseFirestore.Transaction) => {\n const existing = await transaction.get(ref)\n /*\n * A row that is `waiting` or freshly `running` belongs to a live\n * enrollment and this one is a duplicate. A STALE `running` row is a\n * claim nobody is holding (see FLOW_CLAIM_STALE_MS) and a completed\n * flow deletes its row, so anything else is free to be overwritten —\n * which is what lets the same person go through the same welcome series\n * again next year.\n */\n if (existing.exists) {\n const status = existing.get('status')\n const claimedAtMs = Number(existing.get('claimedAtMs') ?? 0)\n const live =\n status === 'waiting' ||\n (status === 'running' && nowMs - claimedAtMs < FLOW_CLAIM_STALE_MS)\n if (live) {\n return { enrolled: false, reason: 'already-waiting' as const }\n }\n }\n const enrollment: FlowEnrollment = {\n hostId: options.hostId,\n ...(options.automation === 'workflow'\n ? { automation: 'workflow' as const }\n : {}),\n actionId: options.actionId,\n actionName: String(options.action.name ?? ''),\n status: 'waiting',\n resumeAtMs: options.resumeAtMs,\n nextStepIndex: options.nextStepIndex,\n steps: [...(options.action.steps ?? [])],\n personKey: key,\n email: String(options.email).trim().toLowerCase(),\n payload: options.payload ?? {},\n event: options.event,\n enrolledAtMs: nowMs,\n updatedAtMs: nowMs,\n resumes: 0,\n ...(options.awaitingEvent\n ? { awaitingEvent: options.awaitingEvent }\n : { awaitingEvent: null }),\n }\n // `set`, not `create`: the transaction above has already decided this id\n // is free, and a completed flow's deleted row may be re-enrolled.\n transaction.set(ref, enrollment)\n return { enrolled: true as const, id: ref.id }\n },\n )\n}\n\n/**\n * Takes an enrollment out of the queue for this beat, or answers null.\n *\n * The claim and the read are one transaction, so two beats — an overlapping\n * schedule, a retried invocation, two regions — cannot both resume the same\n * person. `campaign-process-scheduled.ts` claims a due campaign exactly this\n * way and for exactly this reason.\n */\nexport async function claimFlowEnrollment(\n ref: FirebaseFirestore.DocumentReference,\n options?: { nowMs?: number; firestore?: any },\n): Promise<FlowEnrollment | null> {\n const nowMs = options?.nowMs ?? Date.now()\n const firestore =\n options?.firestore ?? ref.firestore ?? firebaseAdmin.app().firestore()\n return await firestore.runTransaction(\n async (transaction: FirebaseFirestore.Transaction) => {\n const fresh = await transaction.get(ref)\n if (!fresh.exists) return null\n const status = fresh.get('status')\n const claimedAtMs = Number(fresh.get('claimedAtMs') ?? 0)\n const claimable =\n status === 'waiting' ||\n (status === 'running' && nowMs - claimedAtMs >= FLOW_CLAIM_STALE_MS)\n if (!claimable) return null\n transaction.update(ref, {\n status: 'running',\n claimedAtMs: nowMs,\n updatedAtMs: nowMs,\n })\n return { ...(fresh.data() as FlowEnrollment), status: 'running' }\n },\n )\n}\n\n/**\n * Puts a claimed enrollment back without having run it.\n *\n * SKIPPED, NOT DROPPED. Used when the resume cannot proceed for a reason a\n * later beat may pass — the platform's hourly send ceiling had no room, the\n * recipient's own frequency window is full — so the step is attempted again\n * rather than lost. `retryAtMs` pushes the row down the queue so it does not\n * spin on the same refusal every minute.\n */\nexport async function deferFlowEnrollment(\n ref: FirebaseFirestore.DocumentReference,\n retryAtMs: number,\n nowMs = Date.now(),\n): Promise<void> {\n await ref\n .set(\n {\n status: 'waiting',\n resumeAtMs: retryAtMs,\n claimedAtMs: null,\n updatedAtMs: nowMs,\n },\n { merge: true },\n )\n .catch(() => undefined)\n}\n\n/** Advances a claimed enrollment to the next wait, keeping it in the queue. */\nexport async function advanceFlowEnrollment(\n ref: FirebaseFirestore.DocumentReference,\n update: {\n nextStepIndex: number\n resumeAtMs: number\n awaitingEvent?: string | null\n payload?: Record<string, unknown>\n },\n nowMs = Date.now(),\n): Promise<void> {\n await ref\n .set(\n {\n status: 'waiting',\n claimedAtMs: null,\n updatedAtMs: nowMs,\n resumes: firebaseAdmin.firestore.FieldValue.increment(1),\n nextStepIndex: update.nextStepIndex,\n resumeAtMs: update.resumeAtMs,\n awaitingEvent: update.awaitingEvent ?? null,\n ...(update.payload ? { payload: update.payload } : {}),\n },\n { merge: true },\n )\n .catch(() => undefined)\n}\n\n/**\n * Ends an enrollment by DELETING it.\n *\n * The row is live state, not a record. What happened to this person is\n * already written to `hosts/{hostId}/activity` — the same run history every\n * immediate action writes — so keeping a `done` row would be a second,\n * shorter answer to a question that is already answered, growing for ever in\n * a collection the sweep has to index.\n *\n * Deleting is also what lets the same person enter the same flow again: the\n * id is derived from the person, so a tombstone would be a permanent refusal\n * to ever run this sequence for them a second time.\n */\nexport async function endFlowEnrollment(\n ref: FirebaseFirestore.DocumentReference,\n): Promise<void> {\n await ref.delete().catch(() => undefined)\n}\n\n/**\n * The enrollments this person's event should wake, on this site.\n *\n * Keyed, bounded and cheap: three equality filters and a small limit, which\n * Firestore serves by merging single-field indexes. This is what keeps\n * `waitForEvent` off a polling design — nothing scans the enrolled population\n * looking for a match, the event arrives already knowing who it is about.\n *\n * The CALLER decides whether to ask at all. `runEventActions` fires on every\n * page view of every published site, so asking on every event would be a\n * query per visitor; it asks only when the payload names a person, which a\n * page view does not.\n */\nexport async function findFlowEnrollmentsAwaiting(options: {\n hostId: string\n event: string\n email: string\n firestore?: any\n limit?: number\n}): Promise<FirebaseFirestore.QueryDocumentSnapshot[]> {\n const key = personKey(options.email)\n const event = String(options.event ?? '').trim()\n if (!key || !event || !options.hostId) return []\n try {\n const snapshot = await enrollmentsRef(options.hostId, options.firestore)\n .where('awaitingEvent', '==', event)\n .where('personKey', '==', key)\n .where('status', '==', 'waiting')\n .limit(options.limit ?? 5)\n .get()\n return snapshot.docs\n } catch (error) {\n console.error('[flow] awaiting-event lookup failed', options.hostId, error)\n return []\n }\n}\n\n/** Where a partial sweep stopped, so the next beat resumes rather than restarts. */\nexport interface FlowSweepCursor {\n /** Full document path of the last enrollment read. */\n path: string\n}\n\nexport interface FlowSweepResult {\n /** Due enrollments read, against the budget. */\n scanned: number\n /** Enrollments this beat claimed and ran. */\n resumed: number\n /** Rows left untouched because their site is locked. */\n skippedLocked: number\n /** Rows another beat already held. */\n skippedClaimed: number\n /** False when the scan budget ran out — `cursor` says where to resume. */\n complete: boolean\n cursor: FlowSweepCursor | null\n}\n\n/**\n * One pass over the flows whose wait has ended.\n *\n * The RESUME itself is injected. Running a step needs the action executor,\n * which needs entitlements, datasets, webhooks and the mail path; keeping it\n * out of this function is what lets the scheduling contract — due-ness, the\n * claim, the budget, the lock — be exercised without any of that.\n */\nexport async function sweepDueFlowEnrollments(\n gate: PluginJobHostGate,\n options: {\n resume: (\n enrollment: FlowEnrollment,\n ref: FirebaseFirestore.DocumentReference,\n ) => Promise<void>\n nowMs?: number\n scanBudget?: number\n cursor?: FlowSweepCursor | null\n firestore?: any\n },\n): Promise<FlowSweepResult> {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const nowMs = options.nowMs ?? Date.now()\n const budget = options.scanBudget ?? FLOW_RESUME_SCAN_BUDGET\n const result: FlowSweepResult = {\n scanned: 0,\n resumed: 0,\n skippedLocked: 0,\n skippedClaimed: 0,\n complete: true,\n cursor: null,\n }\n let after: FirebaseFirestore.DocumentSnapshot | null = options.cursor?.path\n ? await firestore\n .doc(options.cursor.path)\n .get()\n .catch(() => null)\n : null\n\n for (;;) {\n /*\n * Ordered by `resumeAtMs`, which every writer in this module sets on\n * every write. That is load-bearing twice over: an `orderBy` on a field\n * DROPS documents that lack it, so a writer that ever omitted it would\n * make those enrollments invisible to the only thing that resumes them;\n * and ordering oldest-due-first is what makes a budget fair, because the\n * work a short beat leaves behind is the newest rather than whichever\n * rows Firestore happened to return.\n */\n /*\n * The page is capped by what is LEFT of the budget, not only by\n * `PAGE_SIZE`. Checking the budget between pages would let a run whose\n * budget is smaller than one page read the whole page first — the budget\n * would then describe how much was reported rather than how much was\n * read, which is the opposite of what a read budget is for.\n */\n const remaining = budget - result.scanned\n if (remaining <= 0) break\n let query = firestore\n .collectionGroup(FLOW_ENROLLMENTS_SUBCOLLECTION)\n .where('status', '==', 'waiting')\n .where('resumeAtMs', '<=', nowMs)\n .orderBy('resumeAtMs')\n .limit(Math.min(PAGE_SIZE, remaining))\n if (after) query = query.startAfter(after)\n const page = await query.get()\n if (page.empty) break\n\n for (const doc of page.docs) {\n result.scanned += 1\n after = doc\n const hostId =\n String(doc.get('hostId') ?? '') || (doc.ref.parent.parent?.id ?? '')\n if (!hostId) continue\n /*\n * LOCKDOWN, first in the loop body and before any write. `continue`\n * leaves the row `waiting` and overdue, so the flow resumes on the\n * first beat after the lift rather than being cancelled by a pause.\n */\n if (await gate.isLocked(hostId)) {\n result.skippedLocked += 1\n continue\n }\n const enrollment = await claimFlowEnrollment(doc.ref, { nowMs })\n if (!enrollment) {\n result.skippedClaimed += 1\n continue\n }\n try {\n await options.resume(enrollment, doc.ref)\n result.resumed += 1\n } catch (error) {\n /*\n * One broken flow must not stop the sweep — the same isolation the\n * dynamic-list sweep gives each list. The claim is released so a\n * later beat retries; a permanently broken step therefore retries for\n * as long as the enrollment lives, which is bounded by its own wait\n * ceiling and is the recoverable direction.\n */\n console.error('[flow] resume failed', doc.ref.path, error)\n await deferFlowEnrollment(doc.ref, nowMs + FLOW_CLAIM_STALE_MS, nowMs)\n }\n }\n\n if (result.scanned >= budget) {\n result.complete = false\n result.cursor = after ? { path: after.ref.path } : null\n return result\n }\n if (page.size < Math.min(PAGE_SIZE, remaining)) break\n }\n return result\n}\n"],"names":["personKey","firebaseAdmin","FLOW_ENROLLMENTS_SUBCOLLECTION","FLOW_RESUME_SCAN_BUDGET","PAGE_SIZE","FLOW_CLAIM_STALE_MS","flowEnrollmentId","actionId","key","automation","enrollmentsRef","hostId","firestore","app","collection","doc","enrollInFlow","options","email","enrolled","reason","nowMs","Date","now","ref","runTransaction","transaction","existing","get","exists","status","claimedAtMs","Number","live","enrollment","actionName","String","action","name","resumeAtMs","nextStepIndex","steps","trim","toLowerCase","payload","event","enrolledAtMs","updatedAtMs","resumes","awaitingEvent","set","id","claimFlowEnrollment","fresh","claimable","update","data","deferFlowEnrollment","retryAtMs","merge","catch","undefined","advanceFlowEnrollment","FieldValue","increment","endFlowEnrollment","delete","findFlowEnrollmentsAwaiting","snapshot","where","limit","docs","error","console","sweepDueFlowEnrollments","gate","budget","scanBudget","result","scanned","resumed","skippedLocked","skippedClaimed","complete","cursor","after","path","remaining","query","collectionGroup","orderBy","Math","min","startAfter","page","empty","parent","isLocked","resume","size"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/workflows/src/lib/engine/flow-enrollments.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHERE A PERSON WAITS, between one step of a flow and the next.\n *\n * An action used to be trigger → immediate steps, start to finish inside the\n * request that emitted the event. A `wait` step ends that: the run stops, and\n * something else has to pick it up minutes or weeks later. This module is the\n * something else's memory.\n *\n * ## The scheduling model, and why it survives a deploy\n *\n * There is no timer anywhere. A wait is a ROW — `resumeAtMs` on a document in\n * `hosts/{hostId}/flowEnrollments` — and the resume is a query for rows whose\n * time has come, run by the platform job beat that already exists. Nothing is\n * held in a process, so nothing is lost when the process ends: a deploy, a\n * restart, a cold start and a region failover all leave the row exactly where\n * it was, and the next beat finds it. The two worked examples in this repo\n * take the same shape for the same reason — `campaign-process-scheduled.ts`\n * claims `status: 'scheduled'` campaigns whose `sendAtMs` has passed, and the\n * abandoned-checkout scan re-reads any checkout it did not stamp.\n *\n * A beat that dies mid-resume is the case worth stating. The claim below flips\n * `waiting` → `running` in a transaction, so a second beat cannot pick up the\n * same enrollment; and a `running` row whose beat never came back is re-armed\n * by {@link FLOW_CLAIM_STALE_MS}, which is the difference between \"somebody is\n * working on this\" and \"somebody died holding this\".\n *\n * ## What it costs, which is not \"every enrollment on every beat\"\n *\n * The sweep reads DUE rows and only due rows: the query carries\n * `resumeAtMs <= now`, so a site with ten thousand people waiting three days\n * costs nothing on the beats before those three days are up. That is the whole\n * difference between this and the naive design, which re-reads every enrolled\n * person on every beat to ask whether their time has come.\n *\n * On top of that, {@link FLOW_RESUME_SCAN_BUDGET} bounds one beat's work and a\n * cursor resumes it — the shape `dynamic-list-materialize.ts` uses. The budget\n * matters even though the query is already narrow, because a due row that\n * cannot be acted on (a locked host) stays due: without paging past it, one\n * wall of locked rows at the head of the queue would starve every enrollment\n * behind it for as long as the lock lasted.\n *\n * ## ⛔ A BUDGET NEVER DROPS AN ENROLLMENT\n *\n * Running out of budget stops the sweep where it is. It never marks an\n * enrollment done, never advances its step, and never deletes it — the row\n * stays `waiting` and overdue, and the next beat starts again from the front\n * of the queue, which is where the oldest overdue work is. The same rule the\n * list materializer states: a bound on WORK is not a bound on PEOPLE.\n */\n\nimport { personKey } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport type { PluginJobHostGate } from '@aglyn/aglyn/server'\nimport type { WorkflowStep } from './workflow-steps'\n\n/**\n * `hosts/{hostId}/flowEnrollments/{actionId__personKey}` for an action,\n * `{workflow-<workflowId>__personKey}` for a workflow, and\n * `{org-<automationId>__personKey}` for an org automation waiting on the site\n * it ran on.\n */\nexport const FLOW_ENROLLMENTS_SUBCOLLECTION = 'flowEnrollments'\n\n/**\n * What is waiting, when it is not a site action: `workflow` for a workflow's\n * run and `org` for an org automation's (AGL-3302).\n */\nexport type FlowEnrollmentAutomation = 'workflow' | 'org'\n\n/**\n * Documents one sweep may READ across every site.\n *\n * A bound on work, not on membership — see the note above. Sized well past a\n * normal beat's due queue so the ordinary case never pages at all, and far\n * short of what one API invocation should spend.\n */\nexport const FLOW_RESUME_SCAN_BUDGET = 500\n\n/** Due enrollments read per page. */\nconst PAGE_SIZE = 50\n\n/**\n * How long a claim may go unfinished before another beat may take it.\n *\n * A `running` row is one a beat is part-way through. If that beat's process\n * ends — a deploy mid-resume, a function timeout, an unhandled throw that\n * escaped the isolation — nothing ever clears the flag, and without this the\n * enrollment would be stranded `running` for ever: not waiting, so no sweep\n * finds it; not done, so nobody is told.\n *\n * Ten minutes is far longer than a resume takes (a handful of Firestore reads\n * and at most one email) and far shorter than the shortest wait anybody\n * authors, so the only thing it can re-arm is a claim nobody is holding.\n *\n * The cost of getting it wrong is a step running twice, which is why the\n * window is generous rather than tight.\n */\nexport const FLOW_CLAIM_STALE_MS = 10 * 60_000\n\nexport type FlowEnrollmentStatus = 'waiting' | 'running'\n\n/**\n * Why an enrollment ended. Recorded on the run history, never on the\n * enrollment — the row is deleted when the flow ends, so the activity\n * collection is where \"what happened to this person\" lives.\n */\nexport type FlowEnrollmentEnding =\n /** Every remaining step ran. */\n | 'completed'\n /** An `exitFlow` step, or a guard that ended the run. */\n | 'exited'\n /** The action was deleted, disabled, or lost its entitlement. */\n | 'flow-stopped'\n\nexport interface FlowEnrollment {\n hostId: string\n /**\n * What is waiting: `workflow` for a workflow's run, `org` for an org\n * automation's, absent for an action's — which is every enrollment written\n * before a workflow could wait, so an absent field is read as an action for\n * as long as those rows exist.\n */\n automation?: FlowEnrollmentAutomation\n /**\n * The organization whose automation this is, on an `org` enrollment: the\n * resume re-reads `orgs/{orgId}/automations/{actionId}`, and refuses to\n * continue for a site that has since left the organization.\n */\n orgId?: string\n /** The automation's document id: the action's, the workflow's or the org automation's. */\n actionId: string\n actionName: string\n status: FlowEnrollmentStatus\n /** When this flow continues. ALWAYS set — the sweep orders on it. */\n resumeAtMs: number\n /** Index into {@link steps} of the step that runs on resume. */\n nextStepIndex: number\n /**\n * THE FLOW AS IT WAS WHEN THIS PERSON ENTERED IT.\n *\n * Not a reference to the action's current steps, and this is the whole\n * answer to \"what happens to a flow somebody is already waiting inside\".\n * `nextStepIndex` is a position in a LIST, and an author who reorders,\n * inserts or deletes a step moves every position after it — so resuming a\n * three-day wait against the edited list would deliver whatever now happens\n * to sit at index 4. That is not a changed flow, it is a scrambled one, and\n * the person it happens to is the one who cannot see it.\n *\n * So an edit applies to everyone who enrolls AFTER it, and nobody who is\n * already inside. The trade is that a merchant who fixes a typo in step 3\n * does not fix it for the people mid-wait; the alternative trade is sending\n * them a step from a different flow, which is worse and silent.\n *\n * Bounded by `ACTION_MAX_STEPS` for an action, so the snapshot is ten\n * small objects, and by `WORKFLOW_MAX_STEPS` for a workflow, whose snapshot\n * holds its function calls beside its Actions steps.\n */\n steps: WorkflowStep[]\n /** The event a `waitForEvent` is watching for; absent for a plain wait. */\n awaitingEvent?: string | null\n /** `sha256` of the person's address — the wake lookup key. */\n personKey: string\n email: string\n /** The trigger payload, carried forward so later steps see the same scope. */\n payload: Record<string, unknown>\n /** The event that started the flow, for the run-history line. */\n event: string\n enrolledAtMs: number\n updatedAtMs: number\n /** How many times this enrollment has been picked up. */\n resumes: number\n /** Set while `running`; how {@link FLOW_CLAIM_STALE_MS} is measured. */\n claimedAtMs?: number\n}\n\n/**\n * ONE LIVE ENROLLMENT PER PERSON PER FLOW, by construction.\n *\n * The document id is derived rather than generated, so a second concurrent\n * enrollment is not a race to detect — it is a write to a document that\n * already exists. A shopper who abandons three carts in an hour gets one\n * recovery sequence, not three overlapping ones, and the guarantee holds\n * across processes because it is Firestore's and not a lock of ours.\n *\n * DELIBERATE, and reversible: a flow that genuinely wants concurrent\n * enrollments would key on something narrower than the person (the cart, the\n * order), which is a change to this function and to nothing else.\n */\nexport function flowEnrollmentId(\n actionId: string,\n key: string,\n automation?: FlowEnrollmentAutomation,\n): string {\n // Each kind's id is kept apart from the others', so no two kinds can ever\n // share a row whatever ids their collections hand out — an org automation's\n // id comes from a different collection than the site's own action ids.\n if (automation === 'workflow') return `workflow-${actionId}__${key}`\n if (automation === 'org') return `org-${actionId}__${key}`\n return `${actionId}__${key}`\n}\n\nfunction enrollmentsRef(\n hostId: string,\n firestore?: any,\n): FirebaseFirestore.CollectionReference {\n return (firestore ?? firebaseAdmin.app().firestore())\n .collection('hosts')\n .doc(hostId)\n .collection(FLOW_ENROLLMENTS_SUBCOLLECTION)\n}\n\nexport interface EnrollInFlowOptions {\n hostId: string\n /** `workflow` for a workflow's run, `org` for an org automation's; absent for an action's. */\n automation?: FlowEnrollmentAutomation\n /** The owning organization, on an `org` enrollment. */\n orgId?: string\n /** The automation's document id. */\n actionId: string\n action: { name?: string; steps?: readonly WorkflowStep[] }\n /** The address the flow is about. A flow with no person cannot wait. */\n email: string\n event: string\n payload: Record<string, unknown>\n /** Index of the step that runs when the wait ends. */\n nextStepIndex: number\n resumeAtMs: number\n awaitingEvent?: string | null\n nowMs?: number\n firestore?: any\n}\n\nexport type EnrollInFlowResult =\n | { enrolled: true; id: string }\n | { enrolled: false; reason: 'no-person' | 'already-waiting' }\n\n/**\n * Suspends a run, durably.\n *\n * Refuses rather than improvises when there is nobody to wait for. A flow\n * that waits is a flow that continues later for a PERSON — it is how the\n * enrollment is keyed, how a `waitForEvent` is woken, and who the next step's\n * email is addressed to. An anonymous wait would have no dedupe key, so a\n * page-view trigger on a busy site would mint an enrollment per visit; and\n * nothing downstream could use it. Saying so is better than silently\n * enrolling nobody.\n */\nexport async function enrollInFlow(\n options: EnrollInFlowOptions,\n): Promise<EnrollInFlowResult> {\n const key = personKey(options.email)\n if (!key) return { enrolled: false, reason: 'no-person' }\n const nowMs = options.nowMs ?? Date.now()\n const ref = enrollmentsRef(options.hostId, options.firestore).doc(\n flowEnrollmentId(options.actionId, key, options.automation),\n )\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n return await firestore.runTransaction(\n async (transaction: FirebaseFirestore.Transaction) => {\n const existing = await transaction.get(ref)\n /*\n * A row that is `waiting` or freshly `running` belongs to a live\n * enrollment and this one is a duplicate. A STALE `running` row is a\n * claim nobody is holding (see FLOW_CLAIM_STALE_MS) and a completed\n * flow deletes its row, so anything else is free to be overwritten —\n * which is what lets the same person go through the same welcome series\n * again next year.\n */\n if (existing.exists) {\n const status = existing.get('status')\n const claimedAtMs = Number(existing.get('claimedAtMs') ?? 0)\n const live =\n status === 'waiting' ||\n (status === 'running' && nowMs - claimedAtMs < FLOW_CLAIM_STALE_MS)\n if (live) {\n return { enrolled: false, reason: 'already-waiting' as const }\n }\n }\n const enrollment: FlowEnrollment = {\n hostId: options.hostId,\n ...(options.automation ? { automation: options.automation } : {}),\n ...(options.automation === 'org' && options.orgId\n ? { orgId: options.orgId }\n : {}),\n actionId: options.actionId,\n actionName: String(options.action.name ?? ''),\n status: 'waiting',\n resumeAtMs: options.resumeAtMs,\n nextStepIndex: options.nextStepIndex,\n steps: [...(options.action.steps ?? [])],\n personKey: key,\n email: String(options.email).trim().toLowerCase(),\n payload: options.payload ?? {},\n event: options.event,\n enrolledAtMs: nowMs,\n updatedAtMs: nowMs,\n resumes: 0,\n ...(options.awaitingEvent\n ? { awaitingEvent: options.awaitingEvent }\n : { awaitingEvent: null }),\n }\n // `set`, not `create`: the transaction above has already decided this id\n // is free, and a completed flow's deleted row may be re-enrolled.\n transaction.set(ref, enrollment)\n return { enrolled: true as const, id: ref.id }\n },\n )\n}\n\n/**\n * Takes an enrollment out of the queue for this beat, or answers null.\n *\n * The claim and the read are one transaction, so two beats — an overlapping\n * schedule, a retried invocation, two regions — cannot both resume the same\n * person. `campaign-process-scheduled.ts` claims a due campaign exactly this\n * way and for exactly this reason.\n */\nexport async function claimFlowEnrollment(\n ref: FirebaseFirestore.DocumentReference,\n options?: { nowMs?: number; firestore?: any },\n): Promise<FlowEnrollment | null> {\n const nowMs = options?.nowMs ?? Date.now()\n const firestore =\n options?.firestore ?? ref.firestore ?? firebaseAdmin.app().firestore()\n return await firestore.runTransaction(\n async (transaction: FirebaseFirestore.Transaction) => {\n const fresh = await transaction.get(ref)\n if (!fresh.exists) return null\n const status = fresh.get('status')\n const claimedAtMs = Number(fresh.get('claimedAtMs') ?? 0)\n const claimable =\n status === 'waiting' ||\n (status === 'running' && nowMs - claimedAtMs >= FLOW_CLAIM_STALE_MS)\n if (!claimable) return null\n transaction.update(ref, {\n status: 'running',\n claimedAtMs: nowMs,\n updatedAtMs: nowMs,\n })\n return { ...(fresh.data() as FlowEnrollment), status: 'running' }\n },\n )\n}\n\n/**\n * Puts a claimed enrollment back without having run it.\n *\n * SKIPPED, NOT DROPPED. Used when the resume cannot proceed for a reason a\n * later beat may pass — the platform's hourly send ceiling had no room, the\n * recipient's own frequency window is full — so the step is attempted again\n * rather than lost. `retryAtMs` pushes the row down the queue so it does not\n * spin on the same refusal every minute.\n */\nexport async function deferFlowEnrollment(\n ref: FirebaseFirestore.DocumentReference,\n retryAtMs: number,\n nowMs = Date.now(),\n): Promise<void> {\n await ref\n .set(\n {\n status: 'waiting',\n resumeAtMs: retryAtMs,\n claimedAtMs: null,\n updatedAtMs: nowMs,\n },\n { merge: true },\n )\n .catch(() => undefined)\n}\n\n/** Advances a claimed enrollment to the next wait, keeping it in the queue. */\nexport async function advanceFlowEnrollment(\n ref: FirebaseFirestore.DocumentReference,\n update: {\n nextStepIndex: number\n resumeAtMs: number\n awaitingEvent?: string | null\n payload?: Record<string, unknown>\n },\n nowMs = Date.now(),\n): Promise<void> {\n await ref\n .set(\n {\n status: 'waiting',\n claimedAtMs: null,\n updatedAtMs: nowMs,\n resumes: firebaseAdmin.firestore.FieldValue.increment(1),\n nextStepIndex: update.nextStepIndex,\n resumeAtMs: update.resumeAtMs,\n awaitingEvent: update.awaitingEvent ?? null,\n ...(update.payload ? { payload: update.payload } : {}),\n },\n { merge: true },\n )\n .catch(() => undefined)\n}\n\n/**\n * Ends an enrollment by DELETING it.\n *\n * The row is live state, not a record. What happened to this person is\n * already written to `hosts/{hostId}/activity` — the same run history every\n * immediate action writes — so keeping a `done` row would be a second,\n * shorter answer to a question that is already answered, growing for ever in\n * a collection the sweep has to index.\n *\n * Deleting is also what lets the same person enter the same flow again: the\n * id is derived from the person, so a tombstone would be a permanent refusal\n * to ever run this sequence for them a second time.\n */\nexport async function endFlowEnrollment(\n ref: FirebaseFirestore.DocumentReference,\n): Promise<void> {\n await ref.delete().catch(() => undefined)\n}\n\n/**\n * The enrollments this person's event should wake, on this site.\n *\n * Keyed, bounded and cheap: three equality filters and a small limit, which\n * Firestore serves by merging single-field indexes. This is what keeps\n * `waitForEvent` off a polling design — nothing scans the enrolled population\n * looking for a match, the event arrives already knowing who it is about.\n *\n * The CALLER decides whether to ask at all. `runEventActions` fires on every\n * page view of every published site, so asking on every event would be a\n * query per visitor; it asks only when the payload names a person, which a\n * page view does not.\n */\nexport async function findFlowEnrollmentsAwaiting(options: {\n hostId: string\n event: string\n email: string\n firestore?: any\n limit?: number\n}): Promise<FirebaseFirestore.QueryDocumentSnapshot[]> {\n const key = personKey(options.email)\n const event = String(options.event ?? '').trim()\n if (!key || !event || !options.hostId) return []\n try {\n const snapshot = await enrollmentsRef(options.hostId, options.firestore)\n .where('awaitingEvent', '==', event)\n .where('personKey', '==', key)\n .where('status', '==', 'waiting')\n .limit(options.limit ?? 5)\n .get()\n return snapshot.docs\n } catch (error) {\n console.error('[flow] awaiting-event lookup failed', options.hostId, error)\n return []\n }\n}\n\n/** Where a partial sweep stopped, so the next beat resumes rather than restarts. */\nexport interface FlowSweepCursor {\n /** Full document path of the last enrollment read. */\n path: string\n}\n\nexport interface FlowSweepResult {\n /** Due enrollments read, against the budget. */\n scanned: number\n /** Enrollments this beat claimed and ran. */\n resumed: number\n /** Rows left untouched because their site is locked. */\n skippedLocked: number\n /** Rows another beat already held. */\n skippedClaimed: number\n /** False when the scan budget ran out — `cursor` says where to resume. */\n complete: boolean\n cursor: FlowSweepCursor | null\n}\n\n/**\n * One pass over the flows whose wait has ended.\n *\n * The RESUME itself is injected. Running a step needs the action executor,\n * which needs entitlements, datasets, webhooks and the mail path; keeping it\n * out of this function is what lets the scheduling contract — due-ness, the\n * claim, the budget, the lock — be exercised without any of that.\n */\nexport async function sweepDueFlowEnrollments(\n gate: PluginJobHostGate,\n options: {\n resume: (\n enrollment: FlowEnrollment,\n ref: FirebaseFirestore.DocumentReference,\n ) => Promise<void>\n nowMs?: number\n scanBudget?: number\n cursor?: FlowSweepCursor | null\n firestore?: any\n },\n): Promise<FlowSweepResult> {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const nowMs = options.nowMs ?? Date.now()\n const budget = options.scanBudget ?? FLOW_RESUME_SCAN_BUDGET\n const result: FlowSweepResult = {\n scanned: 0,\n resumed: 0,\n skippedLocked: 0,\n skippedClaimed: 0,\n complete: true,\n cursor: null,\n }\n let after: FirebaseFirestore.DocumentSnapshot | null = options.cursor?.path\n ? await firestore\n .doc(options.cursor.path)\n .get()\n .catch(() => null)\n : null\n\n for (;;) {\n /*\n * Ordered by `resumeAtMs`, which every writer in this module sets on\n * every write. That is load-bearing twice over: an `orderBy` on a field\n * DROPS documents that lack it, so a writer that ever omitted it would\n * make those enrollments invisible to the only thing that resumes them;\n * and ordering oldest-due-first is what makes a budget fair, because the\n * work a short beat leaves behind is the newest rather than whichever\n * rows Firestore happened to return.\n */\n /*\n * The page is capped by what is LEFT of the budget, not only by\n * `PAGE_SIZE`. Checking the budget between pages would let a run whose\n * budget is smaller than one page read the whole page first — the budget\n * would then describe how much was reported rather than how much was\n * read, which is the opposite of what a read budget is for.\n */\n const remaining = budget - result.scanned\n if (remaining <= 0) break\n let query = firestore\n .collectionGroup(FLOW_ENROLLMENTS_SUBCOLLECTION)\n .where('status', '==', 'waiting')\n .where('resumeAtMs', '<=', nowMs)\n .orderBy('resumeAtMs')\n .limit(Math.min(PAGE_SIZE, remaining))\n if (after) query = query.startAfter(after)\n const page = await query.get()\n if (page.empty) break\n\n for (const doc of page.docs) {\n result.scanned += 1\n after = doc\n const hostId =\n String(doc.get('hostId') ?? '') || (doc.ref.parent.parent?.id ?? '')\n if (!hostId) continue\n /*\n * LOCKDOWN, first in the loop body and before any write. `continue`\n * leaves the row `waiting` and overdue, so the flow resumes on the\n * first beat after the lift rather than being cancelled by a pause.\n */\n if (await gate.isLocked(hostId)) {\n result.skippedLocked += 1\n continue\n }\n const enrollment = await claimFlowEnrollment(doc.ref, { nowMs })\n if (!enrollment) {\n result.skippedClaimed += 1\n continue\n }\n try {\n await options.resume(enrollment, doc.ref)\n result.resumed += 1\n } catch (error) {\n /*\n * One broken flow must not stop the sweep — the same isolation the\n * dynamic-list sweep gives each list. The claim is released so a\n * later beat retries; a permanently broken step therefore retries for\n * as long as the enrollment lives, which is bounded by its own wait\n * ceiling and is the recoverable direction.\n */\n console.error('[flow] resume failed', doc.ref.path, error)\n await deferFlowEnrollment(doc.ref, nowMs + FLOW_CLAIM_STALE_MS, nowMs)\n }\n }\n\n if (result.scanned >= budget) {\n result.complete = false\n result.cursor = after ? { path: after.ref.path } : null\n return result\n }\n if (page.size < Math.min(PAGE_SIZE, remaining)) break\n }\n return result\n}\n"],"names":["personKey","firebaseAdmin","FLOW_ENROLLMENTS_SUBCOLLECTION","FLOW_RESUME_SCAN_BUDGET","PAGE_SIZE","FLOW_CLAIM_STALE_MS","flowEnrollmentId","actionId","key","automation","enrollmentsRef","hostId","firestore","app","collection","doc","enrollInFlow","options","email","enrolled","reason","nowMs","Date","now","ref","runTransaction","transaction","existing","get","exists","status","claimedAtMs","Number","live","enrollment","orgId","actionName","String","action","name","resumeAtMs","nextStepIndex","steps","trim","toLowerCase","payload","event","enrolledAtMs","updatedAtMs","resumes","awaitingEvent","set","id","claimFlowEnrollment","fresh","claimable","update","data","deferFlowEnrollment","retryAtMs","merge","catch","undefined","advanceFlowEnrollment","FieldValue","increment","endFlowEnrollment","delete","findFlowEnrollmentsAwaiting","snapshot","where","limit","docs","error","console","sweepDueFlowEnrollments","gate","budget","scanBudget","result","scanned","resumed","skippedLocked","skippedClaimed","complete","cursor","after","path","remaining","query","collectionGroup","orderBy","Math","min","startAfter","page","empty","parent","isLocked","resume","size"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,SAASA,SAAS,QAAQ,sBAAqB;AAC/C,SAASC,aAAa,QAAQ,2BAA0B;AAIxD;;;;;CAKC,GACD,OAAO,MAAMC,iCAAiC,kBAAiB;AAQ/D;;;;;;CAMC,GACD,OAAO,MAAMC,0BAA0B,IAAG;AAE1C,mCAAmC,GACnC,MAAMC,YAAY;AAElB;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,sBAAsB,KAAK,MAAM;AA8E9C;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,iBACdC,QAAgB,EAChBC,GAAW,EACXC,UAAqC;IAErC,0EAA0E;IAC1E,4EAA4E;IAC5E,uEAAuE;IACvE,IAAIA,eAAe,YAAY,OAAO,CAAC,SAAS,EAAEF,SAAS,EAAE,EAAEC,KAAK;IACpE,IAAIC,eAAe,OAAO,OAAO,CAAC,IAAI,EAAEF,SAAS,EAAE,EAAEC,KAAK;IAC1D,OAAO,GAAGD,SAAS,EAAE,EAAEC,KAAK;AAC9B;AAEA,SAASE,eACPC,MAAc,EACdC,SAAe;IAEf,OAAO,CAACA,oBAAAA,YAAaX,cAAcY,GAAG,GAAGD,SAAS,IAC/CE,UAAU,CAAC,SACXC,GAAG,CAACJ,QACJG,UAAU,CAACZ;AAChB;AA2BA;;;;;;;;;;CAUC,GACD,OAAO,eAAec,aACpBC,OAA4B;QAIdA,gBAIIA;IANlB,MAAMT,MAAMR,UAAUiB,QAAQC,KAAK;IACnC,IAAI,CAACV,KAAK,OAAO;QAAEW,UAAU;QAAOC,QAAQ;IAAY;IACxD,MAAMC,SAAQJ,iBAAAA,QAAQI,KAAK,YAAbJ,iBAAiBK,KAAKC,GAAG;IACvC,MAAMC,MAAMd,eAAeO,QAAQN,MAAM,EAAEM,QAAQL,SAAS,EAAEG,GAAG,CAC/DT,iBAAiBW,QAAQV,QAAQ,EAAEC,KAAKS,QAAQR,UAAU;IAE5D,MAAMG,aAAYK,qBAAAA,QAAQL,SAAS,YAAjBK,qBAAqBhB,cAAcY,GAAG,GAAGD,SAAS;IACpE,OAAO,MAAMA,UAAUa,cAAc,CACnC,OAAOC;YA2BgBT,sBAIPA,uBAGHA;QAjCX,MAAMU,WAAW,MAAMD,YAAYE,GAAG,CAACJ;QACvC;;;;;;;OAOC,GACD,IAAIG,SAASE,MAAM,EAAE;gBAEQF;YAD3B,MAAMG,SAASH,SAASC,GAAG,CAAC;YAC5B,MAAMG,cAAcC,QAAOL,gBAAAA,SAASC,GAAG,CAAC,0BAAbD,gBAA+B;YAC1D,MAAMM,OACJH,WAAW,aACVA,WAAW,aAAaT,QAAQU,cAAc1B;YACjD,IAAI4B,MAAM;gBACR,OAAO;oBAAEd,UAAU;oBAAOC,QAAQ;gBAA2B;YAC/D;QACF;QACA,MAAMc,aAA6B;YACjCvB,QAAQM,QAAQN,MAAM;WAClBM,QAAQR,UAAU,GAAG;YAAEA,YAAYQ,QAAQR,UAAU;QAAC,IAAI,CAAC,GAC3DQ,QAAQR,UAAU,KAAK,SAASQ,QAAQkB,KAAK,GAC7C;YAAEA,OAAOlB,QAAQkB,KAAK;QAAC,IACvB,CAAC;YACL5B,UAAUU,QAAQV,QAAQ;YAC1B6B,YAAYC,QAAOpB,uBAAAA,QAAQqB,MAAM,CAACC,IAAI,YAAnBtB,uBAAuB;YAC1Ca,QAAQ;YACRU,YAAYvB,QAAQuB,UAAU;YAC9BC,eAAexB,QAAQwB,aAAa;YACpCC,OAAO;oBAAKzB,wBAAAA,QAAQqB,MAAM,CAACI,KAAK,YAApBzB,wBAAwB,EAAE;aAAE;YACxCjB,WAAWQ;YACXU,OAAOmB,OAAOpB,QAAQC,KAAK,EAAEyB,IAAI,GAAGC,WAAW;YAC/CC,OAAO,GAAE5B,mBAAAA,QAAQ4B,OAAO,YAAf5B,mBAAmB,CAAC;YAC7B6B,OAAO7B,QAAQ6B,KAAK;YACpBC,cAAc1B;YACd2B,aAAa3B;YACb4B,SAAS;WACLhC,QAAQiC,aAAa,GACrB;YAAEA,eAAejC,QAAQiC,aAAa;QAAC,IACvC;YAAEA,eAAe;QAAK;QAE5B,yEAAyE;QACzE,kEAAkE;QAClExB,YAAYyB,GAAG,CAAC3B,KAAKU;QACrB,OAAO;YAAEf,UAAU;YAAeiC,IAAI5B,IAAI4B,EAAE;QAAC;IAC/C;AAEJ;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeC,oBACpB7B,GAAwC,EACxCP,OAA6C;cAI3CA;IAFF,MAAMI,gBAAQJ,2BAAAA,QAASI,KAAK,mBAAIC,KAAKC,GAAG;IACxC,MAAMX,aACJK,iBAAAA,2BAAAA,QAASL,SAAS,oBAAIY,IAAIZ,SAAS,YAAnCK,QAAuChB,cAAcY,GAAG,GAAGD,SAAS;IACtE,OAAO,MAAMA,UAAUa,cAAc,CACnC,OAAOC;YAIsB4B;QAH3B,MAAMA,QAAQ,MAAM5B,YAAYE,GAAG,CAACJ;QACpC,IAAI,CAAC8B,MAAMzB,MAAM,EAAE,OAAO;QAC1B,MAAMC,SAASwB,MAAM1B,GAAG,CAAC;QACzB,MAAMG,cAAcC,QAAOsB,aAAAA,MAAM1B,GAAG,CAAC,0BAAV0B,aAA4B;QACvD,MAAMC,YACJzB,WAAW,aACVA,WAAW,aAAaT,QAAQU,eAAe1B;QAClD,IAAI,CAACkD,WAAW,OAAO;QACvB7B,YAAY8B,MAAM,CAAChC,KAAK;YACtBM,QAAQ;YACRC,aAAaV;YACb2B,aAAa3B;QACf;QACA,OAAO,aAAMiC,MAAMG,IAAI;YAAuB3B,QAAQ;;IACxD;AAEJ;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAe4B,oBACpBlC,GAAwC,EACxCmC,SAAiB,EACjBtC,QAAQC,KAAKC,GAAG,EAAE;IAElB,MAAMC,IACH2B,GAAG,CACF;QACErB,QAAQ;QACRU,YAAYmB;QACZ5B,aAAa;QACbiB,aAAa3B;IACf,GACA;QAAEuC,OAAO;IAAK,GAEfC,KAAK,CAAC,IAAMC;AACjB;AAEA,6EAA6E,GAC7E,OAAO,eAAeC,sBACpBvC,GAAwC,EACxCgC,MAKC,EACDnC,QAAQC,KAAKC,GAAG,EAAE;QAWGiC;IATrB,MAAMhC,IACH2B,GAAG,CACF;QACErB,QAAQ;QACRC,aAAa;QACbiB,aAAa3B;QACb4B,SAAShD,cAAcW,SAAS,CAACoD,UAAU,CAACC,SAAS,CAAC;QACtDxB,eAAee,OAAOf,aAAa;QACnCD,YAAYgB,OAAOhB,UAAU;QAC7BU,aAAa,GAAEM,wBAAAA,OAAON,aAAa,YAApBM,wBAAwB;OACnCA,OAAOX,OAAO,GAAG;QAAEA,SAASW,OAAOX,OAAO;IAAC,IAAI,CAAC,IAEtD;QAAEe,OAAO;IAAK,GAEfC,KAAK,CAAC,IAAMC;AACjB;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeI,kBACpB1C,GAAwC;IAExC,MAAMA,IAAI2C,MAAM,GAAGN,KAAK,CAAC,IAAMC;AACjC;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeM,4BAA4BnD,OAMjD;QAEsBA;IADrB,MAAMT,MAAMR,UAAUiB,QAAQC,KAAK;IACnC,MAAM4B,QAAQT,QAAOpB,iBAAAA,QAAQ6B,KAAK,YAAb7B,iBAAiB,IAAI0B,IAAI;IAC9C,IAAI,CAACnC,OAAO,CAACsC,SAAS,CAAC7B,QAAQN,MAAM,EAAE,OAAO,EAAE;IAChD,IAAI;YAKOM;QAJT,MAAMoD,WAAW,MAAM3D,eAAeO,QAAQN,MAAM,EAAEM,QAAQL,SAAS,EACpE0D,KAAK,CAAC,iBAAiB,MAAMxB,OAC7BwB,KAAK,CAAC,aAAa,MAAM9D,KACzB8D,KAAK,CAAC,UAAU,MAAM,WACtBC,KAAK,EAACtD,iBAAAA,QAAQsD,KAAK,YAAbtD,iBAAiB,GACvBW,GAAG;QACN,OAAOyC,SAASG,IAAI;IACtB,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,uCAAuCxD,QAAQN,MAAM,EAAE8D;QACrE,OAAO,EAAE;IACX;AACF;AAsBA;;;;;;;CAOC,GACD,OAAO,eAAeE,wBACpBC,IAAuB,EACvB3D,OASC;QAEiBA,oBACJA,gBACCA;QASwCA;IAXvD,MAAML,aAAYK,qBAAAA,QAAQL,SAAS,YAAjBK,qBAAqBhB,cAAcY,GAAG,GAAGD,SAAS;IACpE,MAAMS,SAAQJ,iBAAAA,QAAQI,KAAK,YAAbJ,iBAAiBK,KAAKC,GAAG;IACvC,MAAMsD,UAAS5D,sBAAAA,QAAQ6D,UAAU,YAAlB7D,sBAAsBd;IACrC,MAAM4E,SAA0B;QAC9BC,SAAS;QACTC,SAAS;QACTC,eAAe;QACfC,gBAAgB;QAChBC,UAAU;QACVC,QAAQ;IACV;IACA,IAAIC,QAAmDrE,EAAAA,kBAAAA,QAAQoE,MAAM,qBAAdpE,gBAAgBsE,IAAI,IACvE,MAAM3E,UACHG,GAAG,CAACE,QAAQoE,MAAM,CAACE,IAAI,EACvB3D,GAAG,GACHiC,KAAK,CAAC,IAAM,QACf;IAEJ,OAAS;QACP;;;;;;;;KAQC,GACD;;;;;;KAMC,GACD,MAAM2B,YAAYX,SAASE,OAAOC,OAAO;QACzC,IAAIQ,aAAa,GAAG;QACpB,IAAIC,QAAQ7E,UACT8E,eAAe,CAACxF,gCAChBoE,KAAK,CAAC,UAAU,MAAM,WACtBA,KAAK,CAAC,cAAc,MAAMjD,OAC1BsE,OAAO,CAAC,cACRpB,KAAK,CAACqB,KAAKC,GAAG,CAACzF,WAAWoF;QAC7B,IAAIF,OAAOG,QAAQA,MAAMK,UAAU,CAACR;QACpC,MAAMS,OAAO,MAAMN,MAAM7D,GAAG;QAC5B,IAAImE,KAAKC,KAAK,EAAE;QAEhB,KAAK,MAAMjF,OAAOgF,KAAKvB,IAAI,CAAE;gBAIlBzD;gBAA6BA;YAHtCgE,OAAOC,OAAO,IAAI;YAClBM,QAAQvE;YACR,MAAMJ,SACJ0B,QAAOtB,WAAAA,IAAIa,GAAG,CAAC,qBAARb,WAAqB,iBAAQA,yBAAAA,IAAIS,GAAG,CAACyE,MAAM,CAACA,MAAM,qBAArBlF,uBAAuBqC,EAAE,mBAAI;YACnE,IAAI,CAACzC,QAAQ;YACb;;;;OAIC,GACD,IAAI,MAAMiE,KAAKsB,QAAQ,CAACvF,SAAS;gBAC/BoE,OAAOG,aAAa,IAAI;gBACxB;YACF;YACA,MAAMhD,aAAa,MAAMmB,oBAAoBtC,IAAIS,GAAG,EAAE;gBAAEH;YAAM;YAC9D,IAAI,CAACa,YAAY;gBACf6C,OAAOI,cAAc,IAAI;gBACzB;YACF;YACA,IAAI;gBACF,MAAMlE,QAAQkF,MAAM,CAACjE,YAAYnB,IAAIS,GAAG;gBACxCuD,OAAOE,OAAO,IAAI;YACpB,EAAE,OAAOR,OAAO;gBACd;;;;;;SAMC,GACDC,QAAQD,KAAK,CAAC,wBAAwB1D,IAAIS,GAAG,CAAC+D,IAAI,EAAEd;gBACpD,MAAMf,oBAAoB3C,IAAIS,GAAG,EAAEH,QAAQhB,qBAAqBgB;YAClE;QACF;QAEA,IAAI0D,OAAOC,OAAO,IAAIH,QAAQ;YAC5BE,OAAOK,QAAQ,GAAG;YAClBL,OAAOM,MAAM,GAAGC,QAAQ;gBAAEC,MAAMD,MAAM9D,GAAG,CAAC+D,IAAI;YAAC,IAAI;YACnD,OAAOR;QACT;QACA,IAAIgB,KAAKK,IAAI,GAAGR,KAAKC,GAAG,CAACzF,WAAWoF,YAAY;IAClD;IACA,OAAOT;AACT"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type HostActionAlert, type HostFunction, type HostVariable, type HostWorkflow, type PluginJobHostGate } from '@aglyn/aglyn/server';
|
|
17
|
+
import { type HostAction, type HostActionAlert, type HostActionStep, type HostFunction, type HostVariable, type HostWorkflow, type PluginJobHostGate } from '@aglyn/aglyn/server';
|
|
18
18
|
import { type FlowEnrollment, type FlowSweepCursor, type FlowSweepResult } from './flow-enrollments';
|
|
19
19
|
import type { HostEventPayload } from '@aglyn/tenant-runtime/host-event-listeners';
|
|
20
20
|
import { type AutomationWorkflow, type WorkflowStep } from './workflow-steps';
|
|
@@ -70,9 +70,15 @@ export interface WorkflowContext {
|
|
|
70
70
|
* its document id, and its name for the history and the enrollment.
|
|
71
71
|
*/
|
|
72
72
|
export interface AutomationRun {
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* `orgAutomation` for an org automation (AGL-3302) — a run on the event's
|
|
75
|
+
* site of an automation the organization placed there.
|
|
76
|
+
*/
|
|
77
|
+
kind: 'action' | 'workflow' | 'orgAutomation';
|
|
74
78
|
id: string;
|
|
75
79
|
name: string;
|
|
80
|
+
/** The owning organization, on an `orgAutomation` run: a wait enrolls with it. */
|
|
81
|
+
orgId?: string;
|
|
76
82
|
}
|
|
77
83
|
/**
|
|
78
84
|
* The run environment for one automation run: the org's plan gates resolved
|
|
@@ -97,6 +103,36 @@ export declare function makeWorkflowContextLoader(hostRef: FirebaseFirestore.Doc
|
|
|
97
103
|
export declare function workflowContextFromDocs(functionDocs: readonly FirebaseFirestore.QueryDocumentSnapshot[], variableDocs: readonly FirebaseFirestore.QueryDocumentSnapshot[], workflowDocs: readonly FirebaseFirestore.QueryDocumentSnapshot[]): WorkflowContext;
|
|
98
104
|
/** How a run of an action's step list ended. */
|
|
99
105
|
type ActionRunEnding = 'ran' | 'waiting' | 'exited' | 'deferred';
|
|
106
|
+
interface ExecuteActionOptions {
|
|
107
|
+
/**
|
|
108
|
+
* The step to start at. Non-zero only on a resume, where it is the
|
|
109
|
+
* enrollment's `nextStepIndex`.
|
|
110
|
+
*/
|
|
111
|
+
startIndex?: number;
|
|
112
|
+
/**
|
|
113
|
+
* The step list to run.
|
|
114
|
+
*
|
|
115
|
+
* A resume passes the enrollment's SNAPSHOT rather than the action's
|
|
116
|
+
* current steps — see `FlowEnrollment.steps` for why a position in a list
|
|
117
|
+
* is meaningless against a list that has since been edited.
|
|
118
|
+
*/
|
|
119
|
+
steps?: readonly HostActionStep[];
|
|
120
|
+
/**
|
|
121
|
+
* The enrollment this run belongs to. Present only on a resume; a first run
|
|
122
|
+
* mints one if it reaches a wait.
|
|
123
|
+
*/
|
|
124
|
+
enrollmentRef?: FirebaseFirestore.DocumentReference | null;
|
|
125
|
+
/**
|
|
126
|
+
* Present for a run of an ORG automation (AGL-3302), naming the
|
|
127
|
+
* organization that owns it. The steps run on this site exactly as a site
|
|
128
|
+
* action's do; what changes is that each is held to the org vocabulary as
|
|
129
|
+
* it runs, a wait enrolls under the org automation's own id, and the
|
|
130
|
+
* history row is filed under it.
|
|
131
|
+
*/
|
|
132
|
+
orgAutomation?: {
|
|
133
|
+
orgId: string;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
100
136
|
/**
|
|
101
137
|
* What one Actions step did, for the run that holds it.
|
|
102
138
|
*
|
|
@@ -128,6 +164,19 @@ export type ServerStepVerdict = {
|
|
|
128
164
|
} | {
|
|
129
165
|
kind: 'deferred';
|
|
130
166
|
};
|
|
167
|
+
/**
|
|
168
|
+
* Executes one action's SERVER steps in order, collecting per-step errors
|
|
169
|
+
* into the activity summary. Each step runs through {@link runServerStep}.
|
|
170
|
+
*
|
|
171
|
+
* An org automation's run comes through here too (AGL-3302): on the site the
|
|
172
|
+
* event happened on, against that site's run environment, so its email goes
|
|
173
|
+
* from that site and its CRM writes land in that site's facet.
|
|
174
|
+
*
|
|
175
|
+
* A `wait` step ENDS this call and hands the rest of the list to the job
|
|
176
|
+
* beat: everything after the wait belongs to a run that has not happened yet,
|
|
177
|
+
* so nothing below the wait may execute in this request.
|
|
178
|
+
*/
|
|
179
|
+
export declare function executeAction(env: ActionRunEnv, actionId: string, action: Pick<HostAction, 'name' | 'steps'>, event: string, payload: HostEventPayload, options?: ExecuteActionOptions): Promise<ActionRunEnding>;
|
|
131
180
|
/** How a workflow run went, for whoever records or answers for it. */
|
|
132
181
|
export interface WorkflowExecution {
|
|
133
182
|
ending: ActionRunEnding;
|
|
@@ -182,6 +231,10 @@ export declare function executeWorkflow(env: ActionRunEnv, run: AutomationRun, w
|
|
|
182
231
|
* optional filters over the payload, and executes each step list in
|
|
183
232
|
* order. Never throws into the emitting request. Paid feature: the
|
|
184
233
|
* `actions` flag gates and `actionRunsPerMonth` meters runs.
|
|
234
|
+
*
|
|
235
|
+
* The organization's automations placed on this site run here too
|
|
236
|
+
* (AGL-3302), after the site's own, on the same run environment — they run
|
|
237
|
+
* AS this site — and on the same meter.
|
|
185
238
|
*/
|
|
186
239
|
export declare function runEventActions(hostId: string, event: string, payload?: HostEventPayload, depth?: number): Promise<HostActionAlert[]>;
|
|
187
240
|
/**
|
|
@@ -195,8 +248,8 @@ export declare function runSingleAction(hostId: string, actionId: string, event:
|
|
|
195
248
|
/**
|
|
196
249
|
* Ends an enrollment its automation may no longer run, and says why in the
|
|
197
250
|
* run history — the automation was deleted or switched off, or the plan that
|
|
198
|
-
* carried it lapsed. Shared by
|
|
199
|
-
* reads the same for an action and
|
|
251
|
+
* carried it lapsed. Shared by every kind of enrollment, so "stopped mid-wait"
|
|
252
|
+
* reads the same for an action, a workflow and an org automation.
|
|
200
253
|
*/
|
|
201
254
|
export declare function stopFlowEnrollment(enrollment: FlowEnrollment, ref: FirebaseFirestore.DocumentReference, reason: string): Promise<'stopped'>;
|
|
202
255
|
/**
|
|
@@ -28,6 +28,8 @@ import { advanceFlowEnrollment, claimFlowEnrollment, deferFlowEnrollment, endFlo
|
|
|
28
28
|
import { logCrmEmailActivity, prepareCrmEmailActivity, runCrmActionStep } from "./crm-action-steps.js";
|
|
29
29
|
import { resolveDatasetDoc } from "@aglyn/tenant-runtime/resolve-dataset";
|
|
30
30
|
import { resumeWorkflowEnrollment, runEventWorkflows } from "./run-event-workflows.js";
|
|
31
|
+
import { ORG_AUTOMATION_RUN_TARGET, orgAutomationStepRefusal } from "../model/org-automations.js";
|
|
32
|
+
import { findOrgAutomationsForEvent, resumeOrgAutomationEnrollment } from "./run-org-automations.js";
|
|
31
33
|
import { isWorkflowActionStep, workflowActionStepRefusal, workflowHasActionSteps, workflowStepTypeLabel } from "./workflow-steps.js";
|
|
32
34
|
/** Bounded fan-out per event, mirroring the workflow runner. */ const MAX_TRIGGERED_ACTIONS = 10;
|
|
33
35
|
/**
|
|
@@ -755,16 +757,26 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
755
757
|
* Executes one action's SERVER steps in order, collecting per-step errors
|
|
756
758
|
* into the activity summary. Each step runs through {@link runServerStep}.
|
|
757
759
|
*
|
|
760
|
+
* An org automation's run comes through here too (AGL-3302): on the site the
|
|
761
|
+
* event happened on, against that site's run environment, so its email goes
|
|
762
|
+
* from that site and its CRM writes land in that site's facet.
|
|
763
|
+
*
|
|
758
764
|
* A `wait` step ENDS this call and hands the rest of the list to the job
|
|
759
765
|
* beat: everything after the wait belongs to a run that has not happened yet,
|
|
760
766
|
* so nothing below the wait may execute in this request.
|
|
761
|
-
*/ async function executeAction(env, actionId, action, event, payload, options = {}) {
|
|
762
|
-
var _action_name, _options_enrollmentRef, _ref, _options_steps, _options_startIndex,
|
|
767
|
+
*/ export async function executeAction(env, actionId, action, event, payload, options = {}) {
|
|
768
|
+
var _options_orgAutomation, _action_name, _action_name1, _options_enrollmentRef, _ref, _options_steps, _options_startIndex, _action_name2;
|
|
763
769
|
const { hostRef } = env;
|
|
764
|
-
const
|
|
770
|
+
const orgRun = (_options_orgAutomation = options.orgAutomation) != null ? _options_orgAutomation : null;
|
|
771
|
+
const run = orgRun ? {
|
|
772
|
+
kind: 'orgAutomation',
|
|
773
|
+
id: actionId,
|
|
774
|
+
name: (_action_name = action.name) != null ? _action_name : '',
|
|
775
|
+
orgId: orgRun.orgId
|
|
776
|
+
} : {
|
|
765
777
|
kind: 'action',
|
|
766
778
|
id: actionId,
|
|
767
|
-
name: (
|
|
779
|
+
name: (_action_name1 = action.name) != null ? _action_name1 : ''
|
|
768
780
|
};
|
|
769
781
|
const enrollmentRef = (_options_enrollmentRef = options.enrollmentRef) != null ? _options_enrollmentRef : null;
|
|
770
782
|
const steps = ((_ref = (_options_steps = options.steps) != null ? _options_steps : action.steps) != null ? _ref : []).slice(0, ACTION_MAX_STEPS);
|
|
@@ -785,6 +797,16 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
785
797
|
}, payload);
|
|
786
798
|
for(let index = startIndex; index < steps.length; index += 1){
|
|
787
799
|
const step = steps[index];
|
|
800
|
+
/*
|
|
801
|
+
* An org automation runs its own vocabulary and nothing else. The save
|
|
802
|
+
* route refuses the rest, so this answers only a document written around
|
|
803
|
+
* it — and answers it before the step can reach this site's workflows or
|
|
804
|
+
* webhooks, which are not the organization's to call.
|
|
805
|
+
*/ const refusal = orgRun ? orgAutomationStepRefusal(step) : null;
|
|
806
|
+
if (refusal) {
|
|
807
|
+
tally.errors.push(refusal);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
788
810
|
const verdict = await runServerStep(env, run, step, {
|
|
789
811
|
index,
|
|
790
812
|
steps,
|
|
@@ -802,7 +824,8 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
802
824
|
* still ahead of this person, and a row per refused beat would bury the
|
|
803
825
|
* runs that did something under a log of the ceiling working.
|
|
804
826
|
*/ if (ending === 'deferred') return ending;
|
|
805
|
-
const
|
|
827
|
+
const noun = orgRun ? 'Org automation' : 'Action';
|
|
828
|
+
const summary = stepErrors.length ? `${noun} ran on ${event} with errors: ${stepErrors.join('; ')}`.slice(0, 300) : ending === 'waiting' ? `${noun} is waiting, on ${event}` : `${noun} ran on ${event}`;
|
|
806
829
|
await hostRef.collection('activity').add({
|
|
807
830
|
actorId: null,
|
|
808
831
|
actorEmail: null,
|
|
@@ -816,9 +839,11 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
816
839
|
trigger: event,
|
|
817
840
|
summary: (outcomes.length ? outcomes.join(' · ') : 'Ran').slice(0, 300),
|
|
818
841
|
target: {
|
|
819
|
-
type
|
|
842
|
+
// An org automation's run is filed under its own type, so the site's
|
|
843
|
+
// run history can tell it from a site action sharing the feed.
|
|
844
|
+
type: orgRun ? ORG_AUTOMATION_RUN_TARGET : 'workflow',
|
|
820
845
|
id: actionId,
|
|
821
|
-
name: (
|
|
846
|
+
name: (_action_name2 = action.name) != null ? _action_name2 : ''
|
|
822
847
|
},
|
|
823
848
|
createdAt: FieldValue.serverTimestamp()
|
|
824
849
|
}).catch(()=>undefined);
|
|
@@ -946,7 +971,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
946
971
|
* update there" twice is how the two drift into disagreeing about which
|
|
947
972
|
* fields a waiting enrollment carries.
|
|
948
973
|
*/ async function suspendFlow(env, run, request) {
|
|
949
|
-
var _ref, _request_payload;
|
|
974
|
+
var _ref, _run_orgId, _ref1, _request_payload;
|
|
950
975
|
var _request_payload1;
|
|
951
976
|
const { step } = request;
|
|
952
977
|
const minutes = step.type === 'wait' ? Number(step.delayMinutes) : step.type === 'waitForEvent' ? Number(step.timeoutMinutes) : 0;
|
|
@@ -975,6 +1000,9 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
975
1000
|
actionId: run.id
|
|
976
1001
|
}, run.kind === 'workflow' ? {
|
|
977
1002
|
automation: 'workflow'
|
|
1003
|
+
} : {}, run.kind === 'orgAutomation' ? {
|
|
1004
|
+
automation: 'org',
|
|
1005
|
+
orgId: (_ref = (_run_orgId = run.orgId) != null ? _run_orgId : env.orgId) != null ? _ref : ''
|
|
978
1006
|
} : {}, {
|
|
979
1007
|
// The SNAPSHOT is the list this run is executing, which on a first run is
|
|
980
1008
|
// the automation's own — so one edited later cannot move this person's
|
|
@@ -985,7 +1013,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
985
1013
|
...request.steps
|
|
986
1014
|
]
|
|
987
1015
|
},
|
|
988
|
-
email: String((
|
|
1016
|
+
email: String((_ref1 = (_request_payload1 = request.payload) == null ? void 0 : _request_payload1['email']) != null ? _ref1 : ''),
|
|
989
1017
|
event: request.event,
|
|
990
1018
|
payload: (_request_payload = request.payload) != null ? _request_payload : {},
|
|
991
1019
|
nextStepIndex: request.nextStepIndex,
|
|
@@ -1024,7 +1052,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1024
1052
|
*
|
|
1025
1053
|
* Never counts against `actionRunsPerMonth` — nothing executed, and
|
|
1026
1054
|
* charging for a condition that said no would be its own bug.
|
|
1027
|
-
*/ async function recordSkippedRun(hostRef, actionId, action, event) {
|
|
1055
|
+
*/ async function recordSkippedRun(hostRef, actionId, action, event, kind = 'action') {
|
|
1028
1056
|
var _action_name;
|
|
1029
1057
|
if (SKIP_LOG_EXCLUDED_EVENTS.has(event)) return;
|
|
1030
1058
|
// `normalizeTriggerConditions`, not `trigger.conditions` — a pre-AGL-565
|
|
@@ -1038,12 +1066,12 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1038
1066
|
await hostRef.collection('activity').add({
|
|
1039
1067
|
actorId: null,
|
|
1040
1068
|
actorEmail: null,
|
|
1041
|
-
action:
|
|
1069
|
+
action: `${kind === 'orgAutomation' ? 'Org automation' : 'Action'} skipped on ${event}`,
|
|
1042
1070
|
result: 'skipped',
|
|
1043
1071
|
trigger: event,
|
|
1044
1072
|
summary: reason.slice(0, 300),
|
|
1045
1073
|
target: {
|
|
1046
|
-
type: 'workflow',
|
|
1074
|
+
type: kind === 'orgAutomation' ? ORG_AUTOMATION_RUN_TARGET : 'workflow',
|
|
1047
1075
|
id: actionId,
|
|
1048
1076
|
name: (_action_name = action.name) != null ? _action_name : ''
|
|
1049
1077
|
},
|
|
@@ -1056,6 +1084,10 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1056
1084
|
* optional filters over the payload, and executes each step list in
|
|
1057
1085
|
* order. Never throws into the emitting request. Paid feature: the
|
|
1058
1086
|
* `actions` flag gates and `actionRunsPerMonth` meters runs.
|
|
1087
|
+
*
|
|
1088
|
+
* The organization's automations placed on this site run here too
|
|
1089
|
+
* (AGL-3302), after the site's own, on the same run environment — they run
|
|
1090
|
+
* AS this site — and on the same meter.
|
|
1059
1091
|
*/ export async function runEventActions(hostId, event, payload = {}, depth = 0) {
|
|
1060
1092
|
const alerts = [];
|
|
1061
1093
|
if (depth > ACTION_MAX_EVENT_DEPTH) return alerts;
|
|
@@ -1073,18 +1105,39 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1073
1105
|
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
1074
1106
|
const triggered = await hostRef.collection('actions').where('trigger.event', '==', event).limit(MAX_TRIGGERED_ACTIONS).get();
|
|
1075
1107
|
const actions = triggered.docs.filter((doc)=>!doc.get('deletedAt') && doc.get('enabled') !== false);
|
|
1076
|
-
|
|
1108
|
+
/*
|
|
1109
|
+
* THE ORGANIZATION'S AUTOMATIONS placed on this site and not paused here.
|
|
1110
|
+
*
|
|
1111
|
+
* Asked only for an org trigger — a server event a person is the subject
|
|
1112
|
+
* of — so a page view, a custom event and every on-page site event cost
|
|
1113
|
+
* nothing more than they did. Never throws: an org lookup that fails runs
|
|
1114
|
+
* this site's own actions exactly as before.
|
|
1115
|
+
*/ const placed = await findOrgAutomationsForEvent(hostId, event);
|
|
1116
|
+
if (!actions.length && !placed) return alerts;
|
|
1077
1117
|
const monthKey = new Date().toISOString().slice(0, 7);
|
|
1078
1118
|
const runCounterRef = hostRef.collection('counters').doc('actionRuns');
|
|
1079
1119
|
// Webhook steps take the higher `webhooks` gate (AGL-149); plan gates
|
|
1080
1120
|
// ride the owning org's doc (AGL-238).
|
|
1081
1121
|
let webhooksAllowed = true;
|
|
1082
1122
|
let crmAllowed = true;
|
|
1123
|
+
/*
|
|
1124
|
+
* Whether each half fits under the month's allowance — all or nothing per
|
|
1125
|
+
* half, as the site's own actions always were.
|
|
1126
|
+
*
|
|
1127
|
+
* The site's actions are asked first and on exactly the question they
|
|
1128
|
+
* were always asked, so an organization placing automations on a site can
|
|
1129
|
+
* never be what stops that site's own actions running. The org half is
|
|
1130
|
+
* asked second, against what is left after the site's half, and both
|
|
1131
|
+
* count on this site's one meter: an org automation's run is this site's
|
|
1132
|
+
* run, and the usage card, the usage alerts and the COGS rollup read it
|
|
1133
|
+
* there.
|
|
1134
|
+
*/ let runSite = false;
|
|
1135
|
+
let runOrg = false;
|
|
1083
1136
|
// Plan-less orgs resolve as free (AGL-247) — gates always run. Held for
|
|
1084
1137
|
// the rest of the run so the dataset caps below cost no second read.
|
|
1085
1138
|
const owner = await getOrgForHost(hostId);
|
|
1086
1139
|
{
|
|
1087
|
-
var _counterSnapshot_get;
|
|
1140
|
+
var _counterSnapshot_get, _ref2;
|
|
1088
1141
|
const org = owner == null ? void 0 : owner.org;
|
|
1089
1142
|
if (!checkEntitlement(org, 'actions')) return alerts;
|
|
1090
1143
|
webhooksAllowed = checkEntitlement(org, 'webhooks');
|
|
@@ -1092,7 +1145,16 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1092
1145
|
const limit = resolveOrgEntitlements(org).actionRunsPerMonth;
|
|
1093
1146
|
const counterSnapshot = await runCounterRef.get();
|
|
1094
1147
|
const used = Number((_counterSnapshot_get = counterSnapshot.get(monthKey)) != null ? _counterSnapshot_get : 0);
|
|
1095
|
-
|
|
1148
|
+
// Asked as "would it go over", the question this gate has always
|
|
1149
|
+
// asked — so a counter that reads as no number refuses nothing, as
|
|
1150
|
+
// before, rather than refusing everything.
|
|
1151
|
+
runSite = actions.length > 0 && !(used + actions.length > limit);
|
|
1152
|
+
/*
|
|
1153
|
+
* Only for the organization the site belongs to NOW: the automations
|
|
1154
|
+
* were found through the site's org, and a site that changed hands
|
|
1155
|
+
* between the two reads runs none of its old organization's.
|
|
1156
|
+
*/ runOrg = Boolean(placed) && (placed == null ? void 0 : placed.orgId) === (owner == null ? void 0 : owner.orgId) && !(used + (runSite ? actions.length : 0) + ((_ref2 = placed == null ? void 0 : placed.docs.length) != null ? _ref2 : 0) > limit);
|
|
1157
|
+
if (!runSite && !runOrg) return alerts;
|
|
1096
1158
|
}
|
|
1097
1159
|
const env = {
|
|
1098
1160
|
hostId,
|
|
@@ -1108,7 +1170,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1108
1170
|
loadWorkflowContext: makeWorkflowContextLoader(hostRef)
|
|
1109
1171
|
};
|
|
1110
1172
|
let executed = 0;
|
|
1111
|
-
for (const doc of actions){
|
|
1173
|
+
for (const doc of runSite ? actions : []){
|
|
1112
1174
|
var _action_trigger_filter, _action_trigger;
|
|
1113
1175
|
const action = doc.data();
|
|
1114
1176
|
const filter = (_action_trigger = action.trigger) == null ? void 0 : (_action_trigger_filter = _action_trigger.filter) == null ? void 0 : _action_trigger_filter.trim();
|
|
@@ -1138,6 +1200,36 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1138
1200
|
executed += 1;
|
|
1139
1201
|
await executeAction(env, doc.id, action, event, payload);
|
|
1140
1202
|
}
|
|
1203
|
+
/*
|
|
1204
|
+
* The org automations, judged by the same filter and conditions a site
|
|
1205
|
+
* action is, skipped and recorded the same way, and run on this site.
|
|
1206
|
+
*/ for (const doc of runOrg && placed ? placed.docs : []){
|
|
1207
|
+
var _ref3;
|
|
1208
|
+
var _automation_trigger_filter, _automation_trigger;
|
|
1209
|
+
const automation = doc.data();
|
|
1210
|
+
const filter = (_automation_trigger = automation.trigger) == null ? void 0 : (_automation_trigger_filter = _automation_trigger.filter) == null ? void 0 : _automation_trigger_filter.trim();
|
|
1211
|
+
if (filter) {
|
|
1212
|
+
try {
|
|
1213
|
+
if (!evaluateExpression(filter, _extends({
|
|
1214
|
+
event
|
|
1215
|
+
}, payload))) continue;
|
|
1216
|
+
} catch (unused) {
|
|
1217
|
+
continue; // A broken filter never fires.
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (!evaluateTriggerConditions(automation.trigger, _extends({
|
|
1221
|
+
event
|
|
1222
|
+
}, payload))) {
|
|
1223
|
+
await recordSkippedRun(hostRef, doc.id, automation, event, 'orgAutomation');
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
executed += 1;
|
|
1227
|
+
await executeAction(env, doc.id, automation, event, payload, {
|
|
1228
|
+
orgAutomation: {
|
|
1229
|
+
orgId: (_ref3 = placed == null ? void 0 : placed.orgId) != null ? _ref3 : ''
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1141
1233
|
if (executed > 0) {
|
|
1142
1234
|
await runCounterRef.set({
|
|
1143
1235
|
[monthKey]: FieldValue.increment(executed)
|
|
@@ -1223,8 +1315,8 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1223
1315
|
/**
|
|
1224
1316
|
* Ends an enrollment its automation may no longer run, and says why in the
|
|
1225
1317
|
* run history — the automation was deleted or switched off, or the plan that
|
|
1226
|
-
* carried it lapsed. Shared by
|
|
1227
|
-
* reads the same for an action and
|
|
1318
|
+
* carried it lapsed. Shared by every kind of enrollment, so "stopped mid-wait"
|
|
1319
|
+
* reads the same for an action, a workflow and an org automation.
|
|
1228
1320
|
*/ export async function stopFlowEnrollment(enrollment, ref, reason) {
|
|
1229
1321
|
var _enrollment_actionName;
|
|
1230
1322
|
const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(enrollment.hostId);
|
|
@@ -1237,7 +1329,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1237
1329
|
trigger: enrollment.event,
|
|
1238
1330
|
summary: reason.slice(0, 300),
|
|
1239
1331
|
target: {
|
|
1240
|
-
type: 'workflow',
|
|
1332
|
+
type: enrollment.automation === 'org' ? ORG_AUTOMATION_RUN_TARGET : 'workflow',
|
|
1241
1333
|
id: enrollment.actionId,
|
|
1242
1334
|
name: (_enrollment_actionName = enrollment.actionName) != null ? _enrollment_actionName : ''
|
|
1243
1335
|
},
|
|
@@ -1266,6 +1358,14 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1266
1358
|
nowMs
|
|
1267
1359
|
}));
|
|
1268
1360
|
}
|
|
1361
|
+
// An org automation's continues against the ORGANIZATION's document, which
|
|
1362
|
+
// is its kill switch — deleted, switched off, taken off this site or paused
|
|
1363
|
+
// here all stop it (AGL-3302).
|
|
1364
|
+
if (enrollment.automation === 'org') {
|
|
1365
|
+
return await resumeOrgAutomationEnrollment(enrollment, ref, _extends({}, options, {
|
|
1366
|
+
nowMs
|
|
1367
|
+
}));
|
|
1368
|
+
}
|
|
1269
1369
|
const hostId = enrollment.hostId;
|
|
1270
1370
|
const firestore = firebaseAdmin.app().firestore();
|
|
1271
1371
|
const hostRef = firestore.collection('hosts').doc(hostId);
|