@aglyn/plugins-workflows 1.0.0-beta.179 → 1.0.0-beta.181

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 (52) hide show
  1. package/package.json +12 -11
  2. package/src/lib/components/automation-trigger-conditions.component.d.ts +43 -0
  3. package/src/lib/components/automation-trigger-conditions.component.js +189 -0
  4. package/src/lib/components/automation-trigger-conditions.component.js.map +1 -0
  5. package/src/lib/components/host-actions-card.component.js +27 -176
  6. package/src/lib/components/host-actions-card.component.js.map +1 -1
  7. package/src/lib/components/org-automation-editor.component.d.ts +51 -0
  8. package/src/lib/components/org-automation-editor.component.js +310 -0
  9. package/src/lib/components/org-automation-editor.component.js.map +1 -0
  10. package/src/lib/components/org-automations-card.component.d.ts +28 -0
  11. package/src/lib/components/org-automations-card.component.js +373 -0
  12. package/src/lib/components/org-automations-card.component.js.map +1 -0
  13. package/src/lib/components/org-site-automation-list.component.d.ts +43 -0
  14. package/src/lib/components/org-site-automation-list.component.js +289 -0
  15. package/src/lib/components/org-site-automation-list.component.js.map +1 -0
  16. package/src/lib/components/site-org-automations-panel.component.d.ts +22 -0
  17. package/src/lib/components/site-org-automations-panel.component.js +201 -0
  18. package/src/lib/components/site-org-automations-panel.component.js.map +1 -0
  19. package/src/lib/components/use-org-automation-step-pickers.d.ts +20 -0
  20. package/src/lib/components/use-org-automation-step-pickers.js +108 -0
  21. package/src/lib/components/use-org-automation-step-pickers.js.map +1 -0
  22. package/src/lib/components/use-org-automations-api.d.ts +16 -0
  23. package/src/lib/components/use-org-automations-api.js +55 -0
  24. package/src/lib/components/use-org-automations-api.js.map +1 -0
  25. package/src/lib/components/workflows-console-page.js +63 -1
  26. package/src/lib/components/workflows-console-page.js.map +1 -1
  27. package/src/lib/components/workflows-console-sections.d.ts +19 -1
  28. package/src/lib/components/workflows-console-sections.js +37 -2
  29. package/src/lib/components/workflows-console-sections.js.map +1 -1
  30. package/src/lib/components/workflows-org-mount.d.ts +52 -0
  31. package/src/lib/components/workflows-org-mount.js +49 -0
  32. package/src/lib/components/workflows-org-mount.js.map +1 -0
  33. package/src/lib/engine/flow-enrollments.d.ts +26 -10
  34. package/src/lib/engine/flow-enrollments.js +14 -7
  35. package/src/lib/engine/flow-enrollments.js.map +1 -1
  36. package/src/lib/engine/run-event-actions.d.ts +57 -4
  37. package/src/lib/engine/run-event-actions.js +119 -19
  38. package/src/lib/engine/run-event-actions.js.map +1 -1
  39. package/src/lib/engine/run-org-automations.d.ts +75 -0
  40. package/src/lib/engine/run-org-automations.js +154 -0
  41. package/src/lib/engine/run-org-automations.js.map +1 -0
  42. package/src/lib/model/org-automations.d.ts +232 -0
  43. package/src/lib/model/org-automations.js +423 -0
  44. package/src/lib/model/org-automations.js.map +1 -0
  45. package/src/lib/plugin.js +26 -1
  46. package/src/lib/plugin.js.map +1 -1
  47. package/src/lib/server/org-automations-routes.d.ts +88 -0
  48. package/src/lib/server/org-automations-routes.js +474 -0
  49. package/src/lib/server/org-automations-routes.js.map +1 -0
  50. package/src/lib/server-console.d.ts +2 -1
  51. package/src/lib/server-console.js +12 -2
  52. 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, and
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
- // A workflow's id is kept apart from an action's, so the two kinds can
109
- // never share a row whatever ids their collections hand out.
110
- return automation === 'workflow' ? `workflow-${actionId}__${key}` : `${actionId}__${key}`;
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 === 'workflow' ? {
160
- automation: 'workflow'
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":"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"}
@@ -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
- kind: 'action' | 'workflow';
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 both kinds of enrollment, so "stopped mid-wait"
199
- * reads the same for an action and a workflow.
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, _action_name1;
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 run = {
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: (_action_name = action.name) != null ? _action_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 summary = stepErrors.length ? `Action ran on ${event} with errors: ${stepErrors.join('; ')}`.slice(0, 300) : ending === 'waiting' ? `Action is waiting, on ${event}` : `Action ran on ${event}`;
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: 'workflow',
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: (_action_name1 = action.name) != null ? _action_name1 : ''
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((_ref = (_request_payload1 = request.payload) == null ? void 0 : _request_payload1['email']) != null ? _ref : ''),
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: `Action skipped on ${event}`,
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
- if (!actions.length) return alerts;
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
- if (used + actions.length > limit) return alerts;
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 both kinds of enrollment, so "stopped mid-wait"
1227
- * reads the same for an action and a workflow.
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);