Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/engine-trigger-kind-from-spec-resolver.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/service-automation": patch
---

refactor(service-automation): take the trigger KIND from spec's `resolveFlowTriggerKind` instead of a second private copy of the chain (#14328)

No behaviour change and no API change — `patch` because nothing observable moves.
`resolveTriggerBinding` is `private`, no export is added or removed, no payload
key changes, and the kind reported for every flow is the kind reported before
(1,223 `service-automation` cases and 81 `trigger-record-change` cases green
unchanged, plus new pins across the whole precedence chain). What changes is that
one rule now has one home.

**The defect.** `AutomationEngine.resolveTriggerBinding` hand-kept the chain that
decides which trigger a flow asks for — string `record-*` token, array form,
`timeRelative` descriptor, `schedule` cadence or `type: 'schedule'`, `type: 'api'`
or `triggerType: 'api'` — in parallel with `@objectstack/spec`'s
`resolveFlowTriggerKind`, the authoring-time mirror of that same rule. Both
authoring surfaces already read the spec one: `defineStack`'s trigger-capability
refusal and `@objectstack/lint`'s `validate-flow-trigger-readiness`. The engine
did not, and nothing pinned the two together. A branch added to one side leaves
`defineStack` accepting a stack the runtime leaves inert, or refusing one it would
arm — the drift the shared resolver was hoisted to prevent, reopened one layer
down. The two agreed on every string-form flow, so this was an observation rather
than a live defect; the harm was future drift.

**The shape.** `resolveTriggerBinding` now takes its kind from
`resolveFlowTriggerKind(flow)` and keeps only the per-kind BINDING construction —
which start-node fields each trigger needs. `getTriggerBindingAudit` and the boot
banner therefore name the kind authoring named, by construction.

**The one deliberate divergence is preserved, not unified.** The ARRAY form of
`triggerType` (`['record-after-create', 'record-after-delete']`) resolves to *no*
kind in spec — multi-event unions are unsupported (#3457), and reading the shape
as "asks for a record-change trigger" would have `defineStack` demand a capability
the flow can never use and would widen the lint rule's auto-triggered set. The
engine routes it to the record-change trigger anyway, from an explicit pre-check
that runs BEFORE the resolver, for one reason: so that trigger refuses it LOUDLY
at bind time (#3481) instead of the flow folding into "manual" and vanishing from
every surface. Pre-check *ordering* is load-bearing too — array form outranks
`timeRelative`, which the resolver, blind to the array, would otherwise answer for
a start node carrying both.

**What now catches the drift.** Two guards, one static and one runtime. The
per-kind `switch` is exhaustive over `FlowTriggerKind` with a `never` default, so a
kind added to spec fails this package's type-check until its binding shape is
written; and a new case asserts every kind in `FLOW_TRIGGER_KINDS` is reachable
through the real engine. The preserved divergence is pinned on both sides: engine
routing and pre-check precedence in `service-automation`, and the refusal itself —
asserted as a refusal, on a binding the real engine produced — end-to-end against
the real trigger in `@objectstack/trigger-record-change`.
157 changes: 101 additions & 56 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,11 @@ import {
} from './screen-input-contract.js';
import type { Logger } from '@objectstack/spec/contracts';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation';
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
// shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
// `validate-flow-trigger-readiness`, so the runtime cannot drift from what
// authoring accepted. See `resolveTriggerBinding`.
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec';
// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard.
Expand DownExpand Up@@ -2448,6 +2453,17 @@ export class AutomationEngine implements IAutomationService {
* established by the showcase flows — is that the start node carries the
* trigger details in its `config`: `{ objectName, triggerType, condition }`
* for record-change, or a `schedule` descriptor for time-based flows.
*
* [#14328] WHICH KIND a flow asks for is not decided here: it is
* {@link resolveFlowTriggerKind}'s answer, the one `@objectstack/spec`
* export that `defineStack`'s trigger-capability refusal and
* `@objectstack/lint`'s `validate-flow-trigger-readiness` already read.
* This method keeps only the per-kind BINDING construction — which start-node
* fields each trigger needs. Before #14328 the chain was hand-kept here in
* parallel with the spec one, and nothing pinned them together: a branch
* added on one side left `defineStack` accepting a stack the runtime leaves
* inert, or refusing one it would arm. Now `getTriggerBindingAudit` and the
* boot banner name the kind authoring named, by construction.
*/
private resolveTriggerBinding(
flowName: string,
Expand All@@ -2456,20 +2472,7 @@ export class AutomationEngine implements IAutomationService {
if (!flow) return undefined;
const startNode = flow.nodes.find(n => n.type === 'start');
const config = (startNode?.config ?? {}) as Record<string, unknown>;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;

if (triggerType && triggerType.startsWith('record-')) {
return {
triggerType: 'record_change',
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: triggerType,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const condition = (config.condition as FlowTriggerBinding['condition']) ?? undefined;

// Array-form triggerType (e.g. ['record-after-create', 'record-after-delete']).
// Multi-event unions are deliberately unsupported (#3457). But a non-string
Expand All@@ -2483,6 +2486,18 @@ export class AutomationEngine implements IAutomationService {
// raw array is preserved in `config` so the trigger can tailor its message;
// `event` is a joined string so the trigger's single-token mapper reports it
// verbatim and maps it to no hook.
//
// [#14328] This stays an explicit pre-check BEFORE `resolveFlowTriggerKind`,
// and that is the ONE deliberate divergence between the two — documented in
// that resolver's own header. It resolves array form to NO kind on purpose:
// reading it as "asks for a record-change trigger" would have `defineStack`
// demand a capability for a flow that can never use it and would widen the
// lint rule's auto-triggered set. The route below is a DIAGNOSTIC route, not
// a trigger the flow could fire on; folding it into the shared resolver would
// delete a standing decision and turn a loud refusal into silence. Pre-check
// ordering also preserves this method's own precedence exactly: array form
// outranks `timeRelative`/`schedule`, which the resolver — blind to it —
// would otherwise answer for a start node carrying both.
if (
Array.isArray(config.triggerType) &&
config.triggerType.some((t) => typeof t === 'string' && (t as string).startsWith('record-'))
Expand All@@ -2493,56 +2508,86 @@ export class AutomationEngine implements IAutomationService {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: config.triggerType.filter((t) => typeof t === 'string').join(','),
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
condition,
config,
},
};
}

// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. Checked BEFORE `schedule`
// because such a flow ALSO carries a `schedule` cadence (the sweep
// interval) — without this precedence it would bind to the plain schedule
// trigger and fire once with no record instead of once per record.
if (config.timeRelative != null && typeof config.timeRelative === 'object') {
const tr = config.timeRelative as Record<string, unknown>;
return {
triggerType: 'time_relative',
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const kind = resolveFlowTriggerKind(flow);
if (!kind) return undefined;

if (config.schedule != null || flow.type === 'schedule') {
return {
triggerType: 'schedule',
binding: { flowName, schedule: config.schedule, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
switch (kind) {
case 'record_change':
return {
triggerType: kind,
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
// A `record-*` kind means `config.triggerType` IS that string
// token — the resolver read it to answer; the narrowing is
// re-stated because the answer does not carry it back.
event: typeof config.triggerType === 'string' ? config.triggerType : undefined,
condition,
config,
},
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
if (flow.type === 'api' || triggerType === 'api') {
return {
triggerType: 'api',
binding: { flowName, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. The resolver ranks it
// BEFORE `schedule` for the same reason this method did — such a flow
// ALSO carries a `schedule` cadence (the sweep interval), and without
// that precedence it would bind to the plain schedule trigger and fire
// once with no record instead of once per record.
case 'time_relative': {
const tr = (config.timeRelative ?? {}) as Record<string, unknown>;
return {
triggerType: kind,
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition,
config,
},
};
}

case 'schedule':
return {
triggerType: kind,
binding: { flowName, schedule: config.schedule, condition, config },
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
case 'api':
return {
triggerType: kind,
binding: { flowName, condition, config },
};

return undefined;
default: {
// [#14328] Exhaustive over `FlowTriggerKind`, and that is the point:
// a kind added to the spec resolver makes `kind` no longer `never`
// here, so THIS package's type-check fails until the binding shape
// for it is written — the drift is caught at the commit that opens
// it instead of showing up as a flow that arms nowhere. At run time
// (a spec build ahead of this one) fall back to today's behaviour —
// no binding — rather than throwing inside the boot audit.
const unhandledKind: never = kind;
void unhandledKind;
return undefined;
}
}
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/engine-trigger-kind-from-spec-resolver.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/service-automation": patch
---

refactor(service-automation): take the trigger KIND from spec's `resolveFlowTriggerKind` instead of a second private copy of the chain (#14328)

No behaviour change and no API change — `patch` because nothing observable moves.
`resolveTriggerBinding` is `private`, no export is added or removed, no payload
key changes, and the kind reported for every flow is the kind reported before
(1,223 `service-automation` cases and 81 `trigger-record-change` cases green
unchanged, plus new pins across the whole precedence chain). What changes is that
one rule now has one home.

**The defect.** `AutomationEngine.resolveTriggerBinding` hand-kept the chain that
decides which trigger a flow asks for — string `record-*` token, array form,
`timeRelative` descriptor, `schedule` cadence or `type: 'schedule'`, `type: 'api'`
or `triggerType: 'api'` — in parallel with `@objectstack/spec`'s
`resolveFlowTriggerKind`, the authoring-time mirror of that same rule. Both
authoring surfaces already read the spec one: `defineStack`'s trigger-capability
refusal and `@objectstack/lint`'s `validate-flow-trigger-readiness`. The engine
did not, and nothing pinned the two together. A branch added to one side leaves
`defineStack` accepting a stack the runtime leaves inert, or refusing one it would
arm — the drift the shared resolver was hoisted to prevent, reopened one layer
down. The two agreed on every string-form flow, so this was an observation rather
than a live defect; the harm was future drift.

**The shape.** `resolveTriggerBinding` now takes its kind from
`resolveFlowTriggerKind(flow)` and keeps only the per-kind BINDING construction —
which start-node fields each trigger needs. `getTriggerBindingAudit` and the boot
banner therefore name the kind authoring named, by construction.

**The one deliberate divergence is preserved, not unified.** The ARRAY form of
`triggerType` (`['record-after-create', 'record-after-delete']`) resolves to *no*
kind in spec — multi-event unions are unsupported (#3457), and reading the shape
as "asks for a record-change trigger" would have `defineStack` demand a capability
the flow can never use and would widen the lint rule's auto-triggered set. The
engine routes it to the record-change trigger anyway, from an explicit pre-check
that runs BEFORE the resolver, for one reason: so that trigger refuses it LOUDLY
at bind time (#3481) instead of the flow folding into "manual" and vanishing from
every surface. Pre-check *ordering* is load-bearing too — array form outranks
`timeRelative`, which the resolver, blind to the array, would otherwise answer for
a start node carrying both.

**What now catches the drift.** Two guards, one static and one runtime. The
per-kind `switch` is exhaustive over `FlowTriggerKind` with a `never` default, so a
kind added to spec fails this package's type-check until its binding shape is
written; and a new case asserts every kind in `FLOW_TRIGGER_KINDS` is reachable
through the real engine. The preserved divergence is pinned on both sides: engine
routing and pre-check precedence in `service-automation`, and the refusal itself —
asserted as a refusal, on a binding the real engine produced — end-to-end against
the real trigger in `@objectstack/trigger-record-change`.
157 changes: 101 additions & 56 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,11 @@ import {
} from './screen-input-contract.js';
import type { Logger } from '@objectstack/spec/contracts';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation';
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
// shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
// `validate-flow-trigger-readiness`, so the runtime cannot drift from what
// authoring accepted. See `resolveTriggerBinding`.
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec';
// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard.
Expand DownExpand Up@@ -2448,6 +2453,17 @@ export class AutomationEngine implements IAutomationService {
* established by the showcase flows — is that the start node carries the
* trigger details in its `config`: `{ objectName, triggerType, condition }`
* for record-change, or a `schedule` descriptor for time-based flows.
*
* [#14328] WHICH KIND a flow asks for is not decided here: it is
* {@link resolveFlowTriggerKind}'s answer, the one `@objectstack/spec`
* export that `defineStack`'s trigger-capability refusal and
* `@objectstack/lint`'s `validate-flow-trigger-readiness` already read.
* This method keeps only the per-kind BINDING construction — which start-node
* fields each trigger needs. Before #14328 the chain was hand-kept here in
* parallel with the spec one, and nothing pinned them together: a branch
* added on one side left `defineStack` accepting a stack the runtime leaves
* inert, or refusing one it would arm. Now `getTriggerBindingAudit` and the
* boot banner name the kind authoring named, by construction.
*/
private resolveTriggerBinding(
flowName: string,
Expand All@@ -2456,20 +2472,7 @@ export class AutomationEngine implements IAutomationService {
if (!flow) return undefined;
const startNode = flow.nodes.find(n => n.type === 'start');
const config = (startNode?.config ?? {}) as Record<string, unknown>;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;

if (triggerType && triggerType.startsWith('record-')) {
return {
triggerType: 'record_change',
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: triggerType,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const condition = (config.condition as FlowTriggerBinding['condition']) ?? undefined;

// Array-form triggerType (e.g. ['record-after-create', 'record-after-delete']).
// Multi-event unions are deliberately unsupported (#3457). But a non-string
Expand All@@ -2483,6 +2486,18 @@ export class AutomationEngine implements IAutomationService {
// raw array is preserved in `config` so the trigger can tailor its message;
// `event` is a joined string so the trigger's single-token mapper reports it
// verbatim and maps it to no hook.
//
// [#14328] This stays an explicit pre-check BEFORE `resolveFlowTriggerKind`,
// and that is the ONE deliberate divergence between the two — documented in
// that resolver's own header. It resolves array form to NO kind on purpose:
// reading it as "asks for a record-change trigger" would have `defineStack`
// demand a capability for a flow that can never use it and would widen the
// lint rule's auto-triggered set. The route below is a DIAGNOSTIC route, not
// a trigger the flow could fire on; folding it into the shared resolver would
// delete a standing decision and turn a loud refusal into silence. Pre-check
// ordering also preserves this method's own precedence exactly: array form
// outranks `timeRelative`/`schedule`, which the resolver — blind to it —
// would otherwise answer for a start node carrying both.
if (
Array.isArray(config.triggerType) &&
config.triggerType.some((t) => typeof t === 'string' && (t as string).startsWith('record-'))
Expand All@@ -2493,56 +2508,86 @@ export class AutomationEngine implements IAutomationService {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: config.triggerType.filter((t) => typeof t === 'string').join(','),
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
condition,
config,
},
};
}

// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. Checked BEFORE `schedule`
// because such a flow ALSO carries a `schedule` cadence (the sweep
// interval) — without this precedence it would bind to the plain schedule
// trigger and fire once with no record instead of once per record.
if (config.timeRelative != null && typeof config.timeRelative === 'object') {
const tr = config.timeRelative as Record<string, unknown>;
return {
triggerType: 'time_relative',
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const kind = resolveFlowTriggerKind(flow);
if (!kind) return undefined;

if (config.schedule != null || flow.type === 'schedule') {
return {
triggerType: 'schedule',
binding: { flowName, schedule: config.schedule, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
switch (kind) {
case 'record_change':
return {
triggerType: kind,
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
// A `record-*` kind means `config.triggerType` IS that string
// token — the resolver read it to answer; the narrowing is
// re-stated because the answer does not carry it back.
event: typeof config.triggerType === 'string' ? config.triggerType : undefined,
condition,
config,
},
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
if (flow.type === 'api' || triggerType === 'api') {
return {
triggerType: 'api',
binding: { flowName, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. The resolver ranks it
// BEFORE `schedule` for the same reason this method did — such a flow
// ALSO carries a `schedule` cadence (the sweep interval), and without
// that precedence it would bind to the plain schedule trigger and fire
// once with no record instead of once per record.
case 'time_relative': {
const tr = (config.timeRelative ?? {}) as Record<string, unknown>;
return {
triggerType: kind,
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition,
config,
},
};
}

case 'schedule':
return {
triggerType: kind,
binding: { flowName, schedule: config.schedule, condition, config },
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
case 'api':
return {
triggerType: kind,
binding: { flowName, condition, config },
};

return undefined;
default: {
// [#14328] Exhaustive over `FlowTriggerKind`, and that is the point:
// a kind added to the spec resolver makes `kind` no longer `never`
// here, so THIS package's type-check fails until the binding shape
// for it is written — the drift is caught at the commit that opens
// it instead of showing up as a flow that arms nowhere. At run time
// (a spec build ahead of this one) fall back to today's behaviour —
// no binding — rather than throwing inside the boot audit.
const unhandledKind: never = kind;
void unhandledKind;
return undefined;
}
}
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/engine-trigger-kind-from-spec-resolver.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/service-automation": patch
---

refactor(service-automation): take the trigger KIND from spec's `resolveFlowTriggerKind` instead of a second private copy of the chain (#14328)

No behaviour change and no API change — `patch` because nothing observable moves.
`resolveTriggerBinding` is `private`, no export is added or removed, no payload
key changes, and the kind reported for every flow is the kind reported before
(1,223 `service-automation` cases and 81 `trigger-record-change` cases green
unchanged, plus new pins across the whole precedence chain). What changes is that
one rule now has one home.

**The defect.** `AutomationEngine.resolveTriggerBinding` hand-kept the chain that
decides which trigger a flow asks for — string `record-*` token, array form,
`timeRelative` descriptor, `schedule` cadence or `type: 'schedule'`, `type: 'api'`
or `triggerType: 'api'` — in parallel with `@objectstack/spec`'s
`resolveFlowTriggerKind`, the authoring-time mirror of that same rule. Both
authoring surfaces already read the spec one: `defineStack`'s trigger-capability
refusal and `@objectstack/lint`'s `validate-flow-trigger-readiness`. The engine
did not, and nothing pinned the two together. A branch added to one side leaves
`defineStack` accepting a stack the runtime leaves inert, or refusing one it would
arm — the drift the shared resolver was hoisted to prevent, reopened one layer
down. The two agreed on every string-form flow, so this was an observation rather
than a live defect; the harm was future drift.

**The shape.** `resolveTriggerBinding` now takes its kind from
`resolveFlowTriggerKind(flow)` and keeps only the per-kind BINDING construction —
which start-node fields each trigger needs. `getTriggerBindingAudit` and the boot
banner therefore name the kind authoring named, by construction.

**The one deliberate divergence is preserved, not unified.** The ARRAY form of
`triggerType` (`['record-after-create', 'record-after-delete']`) resolves to *no*
kind in spec — multi-event unions are unsupported (#3457), and reading the shape
as "asks for a record-change trigger" would have `defineStack` demand a capability
the flow can never use and would widen the lint rule's auto-triggered set. The
engine routes it to the record-change trigger anyway, from an explicit pre-check
that runs BEFORE the resolver, for one reason: so that trigger refuses it LOUDLY
at bind time (#3481) instead of the flow folding into "manual" and vanishing from
every surface. Pre-check *ordering* is load-bearing too — array form outranks
`timeRelative`, which the resolver, blind to the array, would otherwise answer for
a start node carrying both.

**What now catches the drift.** Two guards, one static and one runtime. The
per-kind `switch` is exhaustive over `FlowTriggerKind` with a `never` default, so a
kind added to spec fails this package's type-check until its binding shape is
written; and a new case asserts every kind in `FLOW_TRIGGER_KINDS` is reachable
through the real engine. The preserved divergence is pinned on both sides: engine
routing and pre-check precedence in `service-automation`, and the refusal itself —
asserted as a refusal, on a binding the real engine produced — end-to-end against
the real trigger in `@objectstack/trigger-record-change`.
157 changes: 101 additions & 56 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,11 @@ import {
} from './screen-input-contract.js';
import type { Logger } from '@objectstack/spec/contracts';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation';
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
// shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
// `validate-flow-trigger-readiness`, so the runtime cannot drift from what
// authoring accepted. See `resolveTriggerBinding`.
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec';
// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard.
Expand DownExpand Up@@ -2448,6 +2453,17 @@ export class AutomationEngine implements IAutomationService {
* established by the showcase flows — is that the start node carries the
* trigger details in its `config`: `{ objectName, triggerType, condition }`
* for record-change, or a `schedule` descriptor for time-based flows.
*
* [#14328] WHICH KIND a flow asks for is not decided here: it is
* {@link resolveFlowTriggerKind}'s answer, the one `@objectstack/spec`
* export that `defineStack`'s trigger-capability refusal and
* `@objectstack/lint`'s `validate-flow-trigger-readiness` already read.
* This method keeps only the per-kind BINDING construction — which start-node
* fields each trigger needs. Before #14328 the chain was hand-kept here in
* parallel with the spec one, and nothing pinned them together: a branch
* added on one side left `defineStack` accepting a stack the runtime leaves
* inert, or refusing one it would arm. Now `getTriggerBindingAudit` and the
* boot banner name the kind authoring named, by construction.
*/
private resolveTriggerBinding(
flowName: string,
Expand All@@ -2456,20 +2472,7 @@ export class AutomationEngine implements IAutomationService {
if (!flow) return undefined;
const startNode = flow.nodes.find(n => n.type === 'start');
const config = (startNode?.config ?? {}) as Record<string, unknown>;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;

if (triggerType && triggerType.startsWith('record-')) {
return {
triggerType: 'record_change',
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: triggerType,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const condition = (config.condition as FlowTriggerBinding['condition']) ?? undefined;

// Array-form triggerType (e.g. ['record-after-create', 'record-after-delete']).
// Multi-event unions are deliberately unsupported (#3457). But a non-string
Expand All@@ -2483,6 +2486,18 @@ export class AutomationEngine implements IAutomationService {
// raw array is preserved in `config` so the trigger can tailor its message;
// `event` is a joined string so the trigger's single-token mapper reports it
// verbatim and maps it to no hook.
//
// [#14328] This stays an explicit pre-check BEFORE `resolveFlowTriggerKind`,
// and that is the ONE deliberate divergence between the two — documented in
// that resolver's own header. It resolves array form to NO kind on purpose:
// reading it as "asks for a record-change trigger" would have `defineStack`
// demand a capability for a flow that can never use it and would widen the
// lint rule's auto-triggered set. The route below is a DIAGNOSTIC route, not
// a trigger the flow could fire on; folding it into the shared resolver would
// delete a standing decision and turn a loud refusal into silence. Pre-check
// ordering also preserves this method's own precedence exactly: array form
// outranks `timeRelative`/`schedule`, which the resolver — blind to it —
// would otherwise answer for a start node carrying both.
if (
Array.isArray(config.triggerType) &&
config.triggerType.some((t) => typeof t === 'string' && (t as string).startsWith('record-'))
Expand All@@ -2493,56 +2508,86 @@ export class AutomationEngine implements IAutomationService {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: config.triggerType.filter((t) => typeof t === 'string').join(','),
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
condition,
config,
},
};
}

// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. Checked BEFORE `schedule`
// because such a flow ALSO carries a `schedule` cadence (the sweep
// interval) — without this precedence it would bind to the plain schedule
// trigger and fire once with no record instead of once per record.
if (config.timeRelative != null && typeof config.timeRelative === 'object') {
const tr = config.timeRelative as Record<string, unknown>;
return {
triggerType: 'time_relative',
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const kind = resolveFlowTriggerKind(flow);
if (!kind) return undefined;

if (config.schedule != null || flow.type === 'schedule') {
return {
triggerType: 'schedule',
binding: { flowName, schedule: config.schedule, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
switch (kind) {
case 'record_change':
return {
triggerType: kind,
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
// A `record-*` kind means `config.triggerType` IS that string
// token — the resolver read it to answer; the narrowing is
// re-stated because the answer does not carry it back.
event: typeof config.triggerType === 'string' ? config.triggerType : undefined,
condition,
config,
},
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
if (flow.type === 'api' || triggerType === 'api') {
return {
triggerType: 'api',
binding: { flowName, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. The resolver ranks it
// BEFORE `schedule` for the same reason this method did — such a flow
// ALSO carries a `schedule` cadence (the sweep interval), and without
// that precedence it would bind to the plain schedule trigger and fire
// once with no record instead of once per record.
case 'time_relative': {
const tr = (config.timeRelative ?? {}) as Record<string, unknown>;
return {
triggerType: kind,
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition,
config,
},
};
}

case 'schedule':
return {
triggerType: kind,
binding: { flowName, schedule: config.schedule, condition, config },
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
case 'api':
return {
triggerType: kind,
binding: { flowName, condition, config },
};

return undefined;
default: {
// [#14328] Exhaustive over `FlowTriggerKind`, and that is the point:
// a kind added to the spec resolver makes `kind` no longer `never`
// here, so THIS package's type-check fails until the binding shape
// for it is written — the drift is caught at the commit that opens
// it instead of showing up as a flow that arms nowhere. At run time
// (a spec build ahead of this one) fall back to today's behaviour —
// no binding — rather than throwing inside the boot audit.
const unhandledKind: never = kind;
void unhandledKind;
return undefined;
}
}
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/engine-trigger-kind-from-spec-resolver.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/service-automation": patch
---

refactor(service-automation): take the trigger KIND from spec's `resolveFlowTriggerKind` instead of a second private copy of the chain (#14328)

No behaviour change and no API change — `patch` because nothing observable moves.
`resolveTriggerBinding` is `private`, no export is added or removed, no payload
key changes, and the kind reported for every flow is the kind reported before
(1,223 `service-automation` cases and 81 `trigger-record-change` cases green
unchanged, plus new pins across the whole precedence chain). What changes is that
one rule now has one home.

**The defect.** `AutomationEngine.resolveTriggerBinding` hand-kept the chain that
decides which trigger a flow asks for — string `record-*` token, array form,
`timeRelative` descriptor, `schedule` cadence or `type: 'schedule'`, `type: 'api'`
or `triggerType: 'api'` — in parallel with `@objectstack/spec`'s
`resolveFlowTriggerKind`, the authoring-time mirror of that same rule. Both
authoring surfaces already read the spec one: `defineStack`'s trigger-capability
refusal and `@objectstack/lint`'s `validate-flow-trigger-readiness`. The engine
did not, and nothing pinned the two together. A branch added to one side leaves
`defineStack` accepting a stack the runtime leaves inert, or refusing one it would
arm — the drift the shared resolver was hoisted to prevent, reopened one layer
down. The two agreed on every string-form flow, so this was an observation rather
than a live defect; the harm was future drift.

**The shape.** `resolveTriggerBinding` now takes its kind from
`resolveFlowTriggerKind(flow)` and keeps only the per-kind BINDING construction —
which start-node fields each trigger needs. `getTriggerBindingAudit` and the boot
banner therefore name the kind authoring named, by construction.

**The one deliberate divergence is preserved, not unified.** The ARRAY form of
`triggerType` (`['record-after-create', 'record-after-delete']`) resolves to *no*
kind in spec — multi-event unions are unsupported (#3457), and reading the shape
as "asks for a record-change trigger" would have `defineStack` demand a capability
the flow can never use and would widen the lint rule's auto-triggered set. The
engine routes it to the record-change trigger anyway, from an explicit pre-check
that runs BEFORE the resolver, for one reason: so that trigger refuses it LOUDLY
at bind time (#3481) instead of the flow folding into "manual" and vanishing from
every surface. Pre-check *ordering* is load-bearing too — array form outranks
`timeRelative`, which the resolver, blind to the array, would otherwise answer for
a start node carrying both.

**What now catches the drift.** Two guards, one static and one runtime. The
per-kind `switch` is exhaustive over `FlowTriggerKind` with a `never` default, so a
kind added to spec fails this package's type-check until its binding shape is
written; and a new case asserts every kind in `FLOW_TRIGGER_KINDS` is reachable
through the real engine. The preserved divergence is pinned on both sides: engine
routing and pre-check precedence in `service-automation`, and the refusal itself —
asserted as a refusal, on a binding the real engine produced — end-to-end against
the real trigger in `@objectstack/trigger-record-change`.
157 changes: 101 additions & 56 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,11 @@ import {
} from './screen-input-contract.js';
import type { Logger } from '@objectstack/spec/contracts';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation';
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
// shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
// `validate-flow-trigger-readiness`, so the runtime cannot drift from what
// authoring accepted. See `resolveTriggerBinding`.
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec';
// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard.
Expand DownExpand Up@@ -2448,6 +2453,17 @@ export class AutomationEngine implements IAutomationService {
* established by the showcase flows — is that the start node carries the
* trigger details in its `config`: `{ objectName, triggerType, condition }`
* for record-change, or a `schedule` descriptor for time-based flows.
*
* [#14328] WHICH KIND a flow asks for is not decided here: it is
* {@link resolveFlowTriggerKind}'s answer, the one `@objectstack/spec`
* export that `defineStack`'s trigger-capability refusal and
* `@objectstack/lint`'s `validate-flow-trigger-readiness` already read.
* This method keeps only the per-kind BINDING construction — which start-node
* fields each trigger needs. Before #14328 the chain was hand-kept here in
* parallel with the spec one, and nothing pinned them together: a branch
* added on one side left `defineStack` accepting a stack the runtime leaves
* inert, or refusing one it would arm. Now `getTriggerBindingAudit` and the
* boot banner name the kind authoring named, by construction.
*/
private resolveTriggerBinding(
flowName: string,
Expand All@@ -2456,20 +2472,7 @@ export class AutomationEngine implements IAutomationService {
if (!flow) return undefined;
const startNode = flow.nodes.find(n => n.type === 'start');
const config = (startNode?.config ?? {}) as Record<string, unknown>;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;

if (triggerType && triggerType.startsWith('record-')) {
return {
triggerType: 'record_change',
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: triggerType,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const condition = (config.condition as FlowTriggerBinding['condition']) ?? undefined;

// Array-form triggerType (e.g. ['record-after-create', 'record-after-delete']).
// Multi-event unions are deliberately unsupported (#3457). But a non-string
Expand All@@ -2483,6 +2486,18 @@ export class AutomationEngine implements IAutomationService {
// raw array is preserved in `config` so the trigger can tailor its message;
// `event` is a joined string so the trigger's single-token mapper reports it
// verbatim and maps it to no hook.
//
// [#14328] This stays an explicit pre-check BEFORE `resolveFlowTriggerKind`,
// and that is the ONE deliberate divergence between the two — documented in
// that resolver's own header. It resolves array form to NO kind on purpose:
// reading it as "asks for a record-change trigger" would have `defineStack`
// demand a capability for a flow that can never use it and would widen the
// lint rule's auto-triggered set. The route below is a DIAGNOSTIC route, not
// a trigger the flow could fire on; folding it into the shared resolver would
// delete a standing decision and turn a loud refusal into silence. Pre-check
// ordering also preserves this method's own precedence exactly: array form
// outranks `timeRelative`/`schedule`, which the resolver — blind to it —
// would otherwise answer for a start node carrying both.
if (
Array.isArray(config.triggerType) &&
config.triggerType.some((t) => typeof t === 'string' && (t as string).startsWith('record-'))
Expand All@@ -2493,56 +2508,86 @@ export class AutomationEngine implements IAutomationService {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: config.triggerType.filter((t) => typeof t === 'string').join(','),
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
condition,
config,
},
};
}

// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. Checked BEFORE `schedule`
// because such a flow ALSO carries a `schedule` cadence (the sweep
// interval) — without this precedence it would bind to the plain schedule
// trigger and fire once with no record instead of once per record.
if (config.timeRelative != null && typeof config.timeRelative === 'object') {
const tr = config.timeRelative as Record<string, unknown>;
return {
triggerType: 'time_relative',
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const kind = resolveFlowTriggerKind(flow);
if (!kind) return undefined;

if (config.schedule != null || flow.type === 'schedule') {
return {
triggerType: 'schedule',
binding: { flowName, schedule: config.schedule, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
switch (kind) {
case 'record_change':
return {
triggerType: kind,
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
// A `record-*` kind means `config.triggerType` IS that string
// token — the resolver read it to answer; the narrowing is
// re-stated because the answer does not carry it back.
event: typeof config.triggerType === 'string' ? config.triggerType : undefined,
condition,
config,
},
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
if (flow.type === 'api' || triggerType === 'api') {
return {
triggerType: 'api',
binding: { flowName, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. The resolver ranks it
// BEFORE `schedule` for the same reason this method did — such a flow
// ALSO carries a `schedule` cadence (the sweep interval), and without
// that precedence it would bind to the plain schedule trigger and fire
// once with no record instead of once per record.
case 'time_relative': {
const tr = (config.timeRelative ?? {}) as Record<string, unknown>;
return {
triggerType: kind,
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition,
config,
},
};
}

case 'schedule':
return {
triggerType: kind,
binding: { flowName, schedule: config.schedule, condition, config },
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
case 'api':
return {
triggerType: kind,
binding: { flowName, condition, config },
};

return undefined;
default: {
// [#14328] Exhaustive over `FlowTriggerKind`, and that is the point:
// a kind added to the spec resolver makes `kind` no longer `never`
// here, so THIS package's type-check fails until the binding shape
// for it is written — the drift is caught at the commit that opens
// it instead of showing up as a flow that arms nowhere. At run time
// (a spec build ahead of this one) fall back to today's behaviour —
// no binding — rather than throwing inside the boot audit.
const unhandledKind: never = kind;
void unhandledKind;
return undefined;
}
}
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/engine-trigger-kind-from-spec-resolver.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/service-automation": patch
---

refactor(service-automation): take the trigger KIND from spec's `resolveFlowTriggerKind` instead of a second private copy of the chain (#14328)

No behaviour change and no API change — `patch` because nothing observable moves.
`resolveTriggerBinding` is `private`, no export is added or removed, no payload
key changes, and the kind reported for every flow is the kind reported before
(1,223 `service-automation` cases and 81 `trigger-record-change` cases green
unchanged, plus new pins across the whole precedence chain). What changes is that
one rule now has one home.

**The defect.** `AutomationEngine.resolveTriggerBinding` hand-kept the chain that
decides which trigger a flow asks for — string `record-*` token, array form,
`timeRelative` descriptor, `schedule` cadence or `type: 'schedule'`, `type: 'api'`
or `triggerType: 'api'` — in parallel with `@objectstack/spec`'s
`resolveFlowTriggerKind`, the authoring-time mirror of that same rule. Both
authoring surfaces already read the spec one: `defineStack`'s trigger-capability
refusal and `@objectstack/lint`'s `validate-flow-trigger-readiness`. The engine
did not, and nothing pinned the two together. A branch added to one side leaves
`defineStack` accepting a stack the runtime leaves inert, or refusing one it would
arm — the drift the shared resolver was hoisted to prevent, reopened one layer
down. The two agreed on every string-form flow, so this was an observation rather
than a live defect; the harm was future drift.

**The shape.** `resolveTriggerBinding` now takes its kind from
`resolveFlowTriggerKind(flow)` and keeps only the per-kind BINDING construction —
which start-node fields each trigger needs. `getTriggerBindingAudit` and the boot
banner therefore name the kind authoring named, by construction.

**The one deliberate divergence is preserved, not unified.** The ARRAY form of
`triggerType` (`['record-after-create', 'record-after-delete']`) resolves to *no*
kind in spec — multi-event unions are unsupported (#3457), and reading the shape
as "asks for a record-change trigger" would have `defineStack` demand a capability
the flow can never use and would widen the lint rule's auto-triggered set. The
engine routes it to the record-change trigger anyway, from an explicit pre-check
that runs BEFORE the resolver, for one reason: so that trigger refuses it LOUDLY
at bind time (#3481) instead of the flow folding into "manual" and vanishing from
every surface. Pre-check *ordering* is load-bearing too — array form outranks
`timeRelative`, which the resolver, blind to the array, would otherwise answer for
a start node carrying both.

**What now catches the drift.** Two guards, one static and one runtime. The
per-kind `switch` is exhaustive over `FlowTriggerKind` with a `never` default, so a
kind added to spec fails this package's type-check until its binding shape is
written; and a new case asserts every kind in `FLOW_TRIGGER_KINDS` is reachable
through the real engine. The preserved divergence is pinned on both sides: engine
routing and pre-check precedence in `service-automation`, and the refusal itself —
asserted as a refusal, on a binding the real engine produced — end-to-end against
the real trigger in `@objectstack/trigger-record-change`.
157 changes: 101 additions & 56 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,11 @@ import {
} from './screen-input-contract.js';
import type { Logger } from '@objectstack/spec/contracts';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation';
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
// shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
// `validate-flow-trigger-readiness`, so the runtime cannot drift from what
// authoring accepted. See `resolveTriggerBinding`.
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec';
// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard.
Expand DownExpand Up@@ -2448,6 +2453,17 @@ export class AutomationEngine implements IAutomationService {
* established by the showcase flows — is that the start node carries the
* trigger details in its `config`: `{ objectName, triggerType, condition }`
* for record-change, or a `schedule` descriptor for time-based flows.
*
* [#14328] WHICH KIND a flow asks for is not decided here: it is
* {@link resolveFlowTriggerKind}'s answer, the one `@objectstack/spec`
* export that `defineStack`'s trigger-capability refusal and
* `@objectstack/lint`'s `validate-flow-trigger-readiness` already read.
* This method keeps only the per-kind BINDING construction — which start-node
* fields each trigger needs. Before #14328 the chain was hand-kept here in
* parallel with the spec one, and nothing pinned them together: a branch
* added on one side left `defineStack` accepting a stack the runtime leaves
* inert, or refusing one it would arm. Now `getTriggerBindingAudit` and the
* boot banner name the kind authoring named, by construction.
*/
private resolveTriggerBinding(
flowName: string,
Expand All@@ -2456,20 +2472,7 @@ export class AutomationEngine implements IAutomationService {
if (!flow) return undefined;
const startNode = flow.nodes.find(n => n.type === 'start');
const config = (startNode?.config ?? {}) as Record<string, unknown>;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;

if (triggerType && triggerType.startsWith('record-')) {
return {
triggerType: 'record_change',
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: triggerType,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const condition = (config.condition as FlowTriggerBinding['condition']) ?? undefined;

// Array-form triggerType (e.g. ['record-after-create', 'record-after-delete']).
// Multi-event unions are deliberately unsupported (#3457). But a non-string
Expand All@@ -2483,6 +2486,18 @@ export class AutomationEngine implements IAutomationService {
// raw array is preserved in `config` so the trigger can tailor its message;
// `event` is a joined string so the trigger's single-token mapper reports it
// verbatim and maps it to no hook.
//
// [#14328] This stays an explicit pre-check BEFORE `resolveFlowTriggerKind`,
// and that is the ONE deliberate divergence between the two — documented in
// that resolver's own header. It resolves array form to NO kind on purpose:
// reading it as "asks for a record-change trigger" would have `defineStack`
// demand a capability for a flow that can never use it and would widen the
// lint rule's auto-triggered set. The route below is a DIAGNOSTIC route, not
// a trigger the flow could fire on; folding it into the shared resolver would
// delete a standing decision and turn a loud refusal into silence. Pre-check
// ordering also preserves this method's own precedence exactly: array form
// outranks `timeRelative`/`schedule`, which the resolver — blind to it —
// would otherwise answer for a start node carrying both.
if (
Array.isArray(config.triggerType) &&
config.triggerType.some((t) => typeof t === 'string' && (t as string).startsWith('record-'))
Expand All@@ -2493,56 +2508,86 @@ export class AutomationEngine implements IAutomationService {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: config.triggerType.filter((t) => typeof t === 'string').join(','),
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
condition,
config,
},
};
}

// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. Checked BEFORE `schedule`
// because such a flow ALSO carries a `schedule` cadence (the sweep
// interval) — without this precedence it would bind to the plain schedule
// trigger and fire once with no record instead of once per record.
if (config.timeRelative != null && typeof config.timeRelative === 'object') {
const tr = config.timeRelative as Record<string, unknown>;
return {
triggerType: 'time_relative',
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const kind = resolveFlowTriggerKind(flow);
if (!kind) return undefined;

if (config.schedule != null || flow.type === 'schedule') {
return {
triggerType: 'schedule',
binding: { flowName, schedule: config.schedule, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
switch (kind) {
case 'record_change':
return {
triggerType: kind,
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
// A `record-*` kind means `config.triggerType` IS that string
// token — the resolver read it to answer; the narrowing is
// re-stated because the answer does not carry it back.
event: typeof config.triggerType === 'string' ? config.triggerType : undefined,
condition,
config,
},
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
if (flow.type === 'api' || triggerType === 'api') {
return {
triggerType: 'api',
binding: { flowName, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. The resolver ranks it
// BEFORE `schedule` for the same reason this method did — such a flow
// ALSO carries a `schedule` cadence (the sweep interval), and without
// that precedence it would bind to the plain schedule trigger and fire
// once with no record instead of once per record.
case 'time_relative': {
const tr = (config.timeRelative ?? {}) as Record<string, unknown>;
return {
triggerType: kind,
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition,
config,
},
};
}

case 'schedule':
return {
triggerType: kind,
binding: { flowName, schedule: config.schedule, condition, config },
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
case 'api':
return {
triggerType: kind,
binding: { flowName, condition, config },
};

return undefined;
default: {
// [#14328] Exhaustive over `FlowTriggerKind`, and that is the point:
// a kind added to the spec resolver makes `kind` no longer `never`
// here, so THIS package's type-check fails until the binding shape
// for it is written — the drift is caught at the commit that opens
// it instead of showing up as a flow that arms nowhere. At run time
// (a spec build ahead of this one) fall back to today's behaviour —
// no binding — rather than throwing inside the boot audit.
const unhandledKind: never = kind;
void unhandledKind;
return undefined;
}
}
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/engine-trigger-kind-from-spec-resolver.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/service-automation": patch
---

refactor(service-automation): take the trigger KIND from spec's `resolveFlowTriggerKind` instead of a second private copy of the chain (#14328)

No behaviour change and no API change — `patch` because nothing observable moves.
`resolveTriggerBinding` is `private`, no export is added or removed, no payload
key changes, and the kind reported for every flow is the kind reported before
(1,223 `service-automation` cases and 81 `trigger-record-change` cases green
unchanged, plus new pins across the whole precedence chain). What changes is that
one rule now has one home.

**The defect.** `AutomationEngine.resolveTriggerBinding` hand-kept the chain that
decides which trigger a flow asks for — string `record-*` token, array form,
`timeRelative` descriptor, `schedule` cadence or `type: 'schedule'`, `type: 'api'`
or `triggerType: 'api'` — in parallel with `@objectstack/spec`'s
`resolveFlowTriggerKind`, the authoring-time mirror of that same rule. Both
authoring surfaces already read the spec one: `defineStack`'s trigger-capability
refusal and `@objectstack/lint`'s `validate-flow-trigger-readiness`. The engine
did not, and nothing pinned the two together. A branch added to one side leaves
`defineStack` accepting a stack the runtime leaves inert, or refusing one it would
arm — the drift the shared resolver was hoisted to prevent, reopened one layer
down. The two agreed on every string-form flow, so this was an observation rather
than a live defect; the harm was future drift.

**The shape.** `resolveTriggerBinding` now takes its kind from
`resolveFlowTriggerKind(flow)` and keeps only the per-kind BINDING construction —
which start-node fields each trigger needs. `getTriggerBindingAudit` and the boot
banner therefore name the kind authoring named, by construction.

**The one deliberate divergence is preserved, not unified.** The ARRAY form of
`triggerType` (`['record-after-create', 'record-after-delete']`) resolves to *no*
kind in spec — multi-event unions are unsupported (#3457), and reading the shape
as "asks for a record-change trigger" would have `defineStack` demand a capability
the flow can never use and would widen the lint rule's auto-triggered set. The
engine routes it to the record-change trigger anyway, from an explicit pre-check
that runs BEFORE the resolver, for one reason: so that trigger refuses it LOUDLY
at bind time (#3481) instead of the flow folding into "manual" and vanishing from
every surface. Pre-check *ordering* is load-bearing too — array form outranks
`timeRelative`, which the resolver, blind to the array, would otherwise answer for
a start node carrying both.

**What now catches the drift.** Two guards, one static and one runtime. The
per-kind `switch` is exhaustive over `FlowTriggerKind` with a `never` default, so a
kind added to spec fails this package's type-check until its binding shape is
written; and a new case asserts every kind in `FLOW_TRIGGER_KINDS` is reachable
through the real engine. The preserved divergence is pinned on both sides: engine
routing and pre-check precedence in `service-automation`, and the refusal itself —
asserted as a refusal, on a binding the real engine produced — end-to-end against
the real trigger in `@objectstack/trigger-record-change`.
157 changes: 101 additions & 56 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,11 @@ import {
} from './screen-input-contract.js';
import type { Logger } from '@objectstack/spec/contracts';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation';
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
// shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
// `validate-flow-trigger-readiness`, so the runtime cannot drift from what
// authoring accepted. See `resolveTriggerBinding`.
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec';
// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard.
Expand DownExpand Up@@ -2448,6 +2453,17 @@ export class AutomationEngine implements IAutomationService {
* established by the showcase flows — is that the start node carries the
* trigger details in its `config`: `{ objectName, triggerType, condition }`
* for record-change, or a `schedule` descriptor for time-based flows.
*
* [#14328] WHICH KIND a flow asks for is not decided here: it is
* {@link resolveFlowTriggerKind}'s answer, the one `@objectstack/spec`
* export that `defineStack`'s trigger-capability refusal and
* `@objectstack/lint`'s `validate-flow-trigger-readiness` already read.
* This method keeps only the per-kind BINDING construction — which start-node
* fields each trigger needs. Before #14328 the chain was hand-kept here in
* parallel with the spec one, and nothing pinned them together: a branch
* added on one side left `defineStack` accepting a stack the runtime leaves
* inert, or refusing one it would arm. Now `getTriggerBindingAudit` and the
* boot banner name the kind authoring named, by construction.
*/
private resolveTriggerBinding(
flowName: string,
Expand All@@ -2456,20 +2472,7 @@ export class AutomationEngine implements IAutomationService {
if (!flow) return undefined;
const startNode = flow.nodes.find(n => n.type === 'start');
const config = (startNode?.config ?? {}) as Record<string, unknown>;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;

if (triggerType && triggerType.startsWith('record-')) {
return {
triggerType: 'record_change',
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: triggerType,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const condition = (config.condition as FlowTriggerBinding['condition']) ?? undefined;

// Array-form triggerType (e.g. ['record-after-create', 'record-after-delete']).
// Multi-event unions are deliberately unsupported (#3457). But a non-string
Expand All@@ -2483,6 +2486,18 @@ export class AutomationEngine implements IAutomationService {
// raw array is preserved in `config` so the trigger can tailor its message;
// `event` is a joined string so the trigger's single-token mapper reports it
// verbatim and maps it to no hook.
//
// [#14328] This stays an explicit pre-check BEFORE `resolveFlowTriggerKind`,
// and that is the ONE deliberate divergence between the two — documented in
// that resolver's own header. It resolves array form to NO kind on purpose:
// reading it as "asks for a record-change trigger" would have `defineStack`
// demand a capability for a flow that can never use it and would widen the
// lint rule's auto-triggered set. The route below is a DIAGNOSTIC route, not
// a trigger the flow could fire on; folding it into the shared resolver would
// delete a standing decision and turn a loud refusal into silence. Pre-check
// ordering also preserves this method's own precedence exactly: array form
// outranks `timeRelative`/`schedule`, which the resolver — blind to it —
// would otherwise answer for a start node carrying both.
if (
Array.isArray(config.triggerType) &&
config.triggerType.some((t) => typeof t === 'string' && (t as string).startsWith('record-'))
Expand All@@ -2493,56 +2508,86 @@ export class AutomationEngine implements IAutomationService {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: config.triggerType.filter((t) => typeof t === 'string').join(','),
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
condition,
config,
},
};
}

// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. Checked BEFORE `schedule`
// because such a flow ALSO carries a `schedule` cadence (the sweep
// interval) — without this precedence it would bind to the plain schedule
// trigger and fire once with no record instead of once per record.
if (config.timeRelative != null && typeof config.timeRelative === 'object') {
const tr = config.timeRelative as Record<string, unknown>;
return {
triggerType: 'time_relative',
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const kind = resolveFlowTriggerKind(flow);
if (!kind) return undefined;

if (config.schedule != null || flow.type === 'schedule') {
return {
triggerType: 'schedule',
binding: { flowName, schedule: config.schedule, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
switch (kind) {
case 'record_change':
return {
triggerType: kind,
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
// A `record-*` kind means `config.triggerType` IS that string
// token — the resolver read it to answer; the narrowing is
// re-stated because the answer does not carry it back.
event: typeof config.triggerType === 'string' ? config.triggerType : undefined,
condition,
config,
},
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
if (flow.type === 'api' || triggerType === 'api') {
return {
triggerType: 'api',
binding: { flowName, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. The resolver ranks it
// BEFORE `schedule` for the same reason this method did — such a flow
// ALSO carries a `schedule` cadence (the sweep interval), and without
// that precedence it would bind to the plain schedule trigger and fire
// once with no record instead of once per record.
case 'time_relative': {
const tr = (config.timeRelative ?? {}) as Record<string, unknown>;
return {
triggerType: kind,
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition,
config,
},
};
}

case 'schedule':
return {
triggerType: kind,
binding: { flowName, schedule: config.schedule, condition, config },
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
case 'api':
return {
triggerType: kind,
binding: { flowName, condition, config },
};

return undefined;
default: {
// [#14328] Exhaustive over `FlowTriggerKind`, and that is the point:
// a kind added to the spec resolver makes `kind` no longer `never`
// here, so THIS package's type-check fails until the binding shape
// for it is written — the drift is caught at the commit that opens
// it instead of showing up as a flow that arms nowhere. At run time
// (a spec build ahead of this one) fall back to today's behaviour —
// no binding — rather than throwing inside the boot audit.
const unhandledKind: never = kind;
void unhandledKind;
return undefined;
}
}
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/engine-trigger-kind-from-spec-resolver.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/service-automation": patch
---

refactor(service-automation): take the trigger KIND from spec's `resolveFlowTriggerKind` instead of a second private copy of the chain (#14328)

No behaviour change and no API change — `patch` because nothing observable moves.
`resolveTriggerBinding` is `private`, no export is added or removed, no payload
key changes, and the kind reported for every flow is the kind reported before
(1,223 `service-automation` cases and 81 `trigger-record-change` cases green
unchanged, plus new pins across the whole precedence chain). What changes is that
one rule now has one home.

**The defect.** `AutomationEngine.resolveTriggerBinding` hand-kept the chain that
decides which trigger a flow asks for — string `record-*` token, array form,
`timeRelative` descriptor, `schedule` cadence or `type: 'schedule'`, `type: 'api'`
or `triggerType: 'api'` — in parallel with `@objectstack/spec`'s
`resolveFlowTriggerKind`, the authoring-time mirror of that same rule. Both
authoring surfaces already read the spec one: `defineStack`'s trigger-capability
refusal and `@objectstack/lint`'s `validate-flow-trigger-readiness`. The engine
did not, and nothing pinned the two together. A branch added to one side leaves
`defineStack` accepting a stack the runtime leaves inert, or refusing one it would
arm — the drift the shared resolver was hoisted to prevent, reopened one layer
down. The two agreed on every string-form flow, so this was an observation rather
than a live defect; the harm was future drift.

**The shape.** `resolveTriggerBinding` now takes its kind from
`resolveFlowTriggerKind(flow)` and keeps only the per-kind BINDING construction —
which start-node fields each trigger needs. `getTriggerBindingAudit` and the boot
banner therefore name the kind authoring named, by construction.

**The one deliberate divergence is preserved, not unified.** The ARRAY form of
`triggerType` (`['record-after-create', 'record-after-delete']`) resolves to *no*
kind in spec — multi-event unions are unsupported (#3457), and reading the shape
as "asks for a record-change trigger" would have `defineStack` demand a capability
the flow can never use and would widen the lint rule's auto-triggered set. The
engine routes it to the record-change trigger anyway, from an explicit pre-check
that runs BEFORE the resolver, for one reason: so that trigger refuses it LOUDLY
at bind time (#3481) instead of the flow folding into "manual" and vanishing from
every surface. Pre-check *ordering* is load-bearing too — array form outranks
`timeRelative`, which the resolver, blind to the array, would otherwise answer for
a start node carrying both.

**What now catches the drift.** Two guards, one static and one runtime. The
per-kind `switch` is exhaustive over `FlowTriggerKind` with a `never` default, so a
kind added to spec fails this package's type-check until its binding shape is
written; and a new case asserts every kind in `FLOW_TRIGGER_KINDS` is reachable
through the real engine. The preserved divergence is pinned on both sides: engine
routing and pre-check precedence in `service-automation`, and the refusal itself —
asserted as a refusal, on a binding the real engine produced — end-to-end against
the real trigger in `@objectstack/trigger-record-change`.
157 changes: 101 additions & 56 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,11 @@ import {
} from './screen-input-contract.js';
import type { Logger } from '@objectstack/spec/contracts';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation';
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
// shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
// `validate-flow-trigger-readiness`, so the runtime cannot drift from what
// authoring accepted. See `resolveTriggerBinding`.
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec';
// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard.
Expand DownExpand Up@@ -2448,6 +2453,17 @@ export class AutomationEngine implements IAutomationService {
* established by the showcase flows — is that the start node carries the
* trigger details in its `config`: `{ objectName, triggerType, condition }`
* for record-change, or a `schedule` descriptor for time-based flows.
*
* [#14328] WHICH KIND a flow asks for is not decided here: it is
* {@link resolveFlowTriggerKind}'s answer, the one `@objectstack/spec`
* export that `defineStack`'s trigger-capability refusal and
* `@objectstack/lint`'s `validate-flow-trigger-readiness` already read.
* This method keeps only the per-kind BINDING construction — which start-node
* fields each trigger needs. Before #14328 the chain was hand-kept here in
* parallel with the spec one, and nothing pinned them together: a branch
* added on one side left `defineStack` accepting a stack the runtime leaves
* inert, or refusing one it would arm. Now `getTriggerBindingAudit` and the
* boot banner name the kind authoring named, by construction.
*/
private resolveTriggerBinding(
flowName: string,
Expand All@@ -2456,20 +2472,7 @@ export class AutomationEngine implements IAutomationService {
if (!flow) return undefined;
const startNode = flow.nodes.find(n => n.type === 'start');
const config = (startNode?.config ?? {}) as Record<string, unknown>;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;

if (triggerType && triggerType.startsWith('record-')) {
return {
triggerType: 'record_change',
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: triggerType,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const condition = (config.condition as FlowTriggerBinding['condition']) ?? undefined;

// Array-form triggerType (e.g. ['record-after-create', 'record-after-delete']).
// Multi-event unions are deliberately unsupported (#3457). But a non-string
Expand All@@ -2483,6 +2486,18 @@ export class AutomationEngine implements IAutomationService {
// raw array is preserved in `config` so the trigger can tailor its message;
// `event` is a joined string so the trigger's single-token mapper reports it
// verbatim and maps it to no hook.
//
// [#14328] This stays an explicit pre-check BEFORE `resolveFlowTriggerKind`,
// and that is the ONE deliberate divergence between the two — documented in
// that resolver's own header. It resolves array form to NO kind on purpose:
// reading it as "asks for a record-change trigger" would have `defineStack`
// demand a capability for a flow that can never use it and would widen the
// lint rule's auto-triggered set. The route below is a DIAGNOSTIC route, not
// a trigger the flow could fire on; folding it into the shared resolver would
// delete a standing decision and turn a loud refusal into silence. Pre-check
// ordering also preserves this method's own precedence exactly: array form
// outranks `timeRelative`/`schedule`, which the resolver — blind to it —
// would otherwise answer for a start node carrying both.
if (
Array.isArray(config.triggerType) &&
config.triggerType.some((t) => typeof t === 'string' && (t as string).startsWith('record-'))
Expand All@@ -2493,56 +2508,86 @@ export class AutomationEngine implements IAutomationService {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: config.triggerType.filter((t) => typeof t === 'string').join(','),
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
condition,
config,
},
};
}

// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. Checked BEFORE `schedule`
// because such a flow ALSO carries a `schedule` cadence (the sweep
// interval) — without this precedence it would bind to the plain schedule
// trigger and fire once with no record instead of once per record.
if (config.timeRelative != null && typeof config.timeRelative === 'object') {
const tr = config.timeRelative as Record<string, unknown>;
return {
triggerType: 'time_relative',
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const kind = resolveFlowTriggerKind(flow);
if (!kind) return undefined;

if (config.schedule != null || flow.type === 'schedule') {
return {
triggerType: 'schedule',
binding: { flowName, schedule: config.schedule, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
switch (kind) {
case 'record_change':
return {
triggerType: kind,
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
// A `record-*` kind means `config.triggerType` IS that string
// token — the resolver read it to answer; the narrowing is
// re-stated because the answer does not carry it back.
event: typeof config.triggerType === 'string' ? config.triggerType : undefined,
condition,
config,
},
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
if (flow.type === 'api' || triggerType === 'api') {
return {
triggerType: 'api',
binding: { flowName, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. The resolver ranks it
// BEFORE `schedule` for the same reason this method did — such a flow
// ALSO carries a `schedule` cadence (the sweep interval), and without
// that precedence it would bind to the plain schedule trigger and fire
// once with no record instead of once per record.
case 'time_relative': {
const tr = (config.timeRelative ?? {}) as Record<string, unknown>;
return {
triggerType: kind,
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition,
config,
},
};
}

case 'schedule':
return {
triggerType: kind,
binding: { flowName, schedule: config.schedule, condition, config },
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
case 'api':
return {
triggerType: kind,
binding: { flowName, condition, config },
};

return undefined;
default: {
// [#14328] Exhaustive over `FlowTriggerKind`, and that is the point:
// a kind added to the spec resolver makes `kind` no longer `never`
// here, so THIS package's type-check fails until the binding shape
// for it is written — the drift is caught at the commit that opens
// it instead of showing up as a flow that arms nowhere. At run time
// (a spec build ahead of this one) fall back to today's behaviour —
// no binding — rather than throwing inside the boot audit.
const unhandledKind: never = kind;
void unhandledKind;
return undefined;
}
}
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/engine-trigger-kind-from-spec-resolver.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/service-automation": patch
---

refactor(service-automation): take the trigger KIND from spec's `resolveFlowTriggerKind` instead of a second private copy of the chain (#14328)

No behaviour change and no API change — `patch` because nothing observable moves.
`resolveTriggerBinding` is `private`, no export is added or removed, no payload
key changes, and the kind reported for every flow is the kind reported before
(1,223 `service-automation` cases and 81 `trigger-record-change` cases green
unchanged, plus new pins across the whole precedence chain). What changes is that
one rule now has one home.

**The defect.** `AutomationEngine.resolveTriggerBinding` hand-kept the chain that
decides which trigger a flow asks for — string `record-*` token, array form,
`timeRelative` descriptor, `schedule` cadence or `type: 'schedule'`, `type: 'api'`
or `triggerType: 'api'` — in parallel with `@objectstack/spec`'s
`resolveFlowTriggerKind`, the authoring-time mirror of that same rule. Both
authoring surfaces already read the spec one: `defineStack`'s trigger-capability
refusal and `@objectstack/lint`'s `validate-flow-trigger-readiness`. The engine
did not, and nothing pinned the two together. A branch added to one side leaves
`defineStack` accepting a stack the runtime leaves inert, or refusing one it would
arm — the drift the shared resolver was hoisted to prevent, reopened one layer
down. The two agreed on every string-form flow, so this was an observation rather
than a live defect; the harm was future drift.

**The shape.** `resolveTriggerBinding` now takes its kind from
`resolveFlowTriggerKind(flow)` and keeps only the per-kind BINDING construction —
which start-node fields each trigger needs. `getTriggerBindingAudit` and the boot
banner therefore name the kind authoring named, by construction.

**The one deliberate divergence is preserved, not unified.** The ARRAY form of
`triggerType` (`['record-after-create', 'record-after-delete']`) resolves to *no*
kind in spec — multi-event unions are unsupported (#3457), and reading the shape
as "asks for a record-change trigger" would have `defineStack` demand a capability
the flow can never use and would widen the lint rule's auto-triggered set. The
engine routes it to the record-change trigger anyway, from an explicit pre-check
that runs BEFORE the resolver, for one reason: so that trigger refuses it LOUDLY
at bind time (#3481) instead of the flow folding into "manual" and vanishing from
every surface. Pre-check *ordering* is load-bearing too — array form outranks
`timeRelative`, which the resolver, blind to the array, would otherwise answer for
a start node carrying both.

**What now catches the drift.** Two guards, one static and one runtime. The
per-kind `switch` is exhaustive over `FlowTriggerKind` with a `never` default, so a
kind added to spec fails this package's type-check until its binding shape is
written; and a new case asserts every kind in `FLOW_TRIGGER_KINDS` is reachable
through the real engine. The preserved divergence is pinned on both sides: engine
routing and pre-check precedence in `service-automation`, and the refusal itself —
asserted as a refusal, on a binding the real engine produced — end-to-end against
the real trigger in `@objectstack/trigger-record-change`.
157 changes: 101 additions & 56 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,11 @@ import {
} from './screen-input-contract.js';
import type { Logger } from '@objectstack/spec/contracts';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation';
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
// shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s
// `validate-flow-trigger-readiness`, so the runtime cannot drift from what
// authoring accepted. See `resolveTriggerBinding`.
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec';
// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard.
Expand DownExpand Up@@ -2448,6 +2453,17 @@ export class AutomationEngine implements IAutomationService {
* established by the showcase flows — is that the start node carries the
* trigger details in its `config`: `{ objectName, triggerType, condition }`
* for record-change, or a `schedule` descriptor for time-based flows.
*
* [#14328] WHICH KIND a flow asks for is not decided here: it is
* {@link resolveFlowTriggerKind}'s answer, the one `@objectstack/spec`
* export that `defineStack`'s trigger-capability refusal and
* `@objectstack/lint`'s `validate-flow-trigger-readiness` already read.
* This method keeps only the per-kind BINDING construction — which start-node
* fields each trigger needs. Before #14328 the chain was hand-kept here in
* parallel with the spec one, and nothing pinned them together: a branch
* added on one side left `defineStack` accepting a stack the runtime leaves
* inert, or refusing one it would arm. Now `getTriggerBindingAudit` and the
* boot banner name the kind authoring named, by construction.
*/
private resolveTriggerBinding(
flowName: string,
Expand All@@ -2456,20 +2472,7 @@ export class AutomationEngine implements IAutomationService {
if (!flow) return undefined;
const startNode = flow.nodes.find(n => n.type === 'start');
const config = (startNode?.config ?? {}) as Record<string, unknown>;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;

if (triggerType && triggerType.startsWith('record-')) {
return {
triggerType: 'record_change',
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: triggerType,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const condition = (config.condition as FlowTriggerBinding['condition']) ?? undefined;

// Array-form triggerType (e.g. ['record-after-create', 'record-after-delete']).
// Multi-event unions are deliberately unsupported (#3457). But a non-string
Expand All@@ -2483,6 +2486,18 @@ export class AutomationEngine implements IAutomationService {
// raw array is preserved in `config` so the trigger can tailor its message;
// `event` is a joined string so the trigger's single-token mapper reports it
// verbatim and maps it to no hook.
//
// [#14328] This stays an explicit pre-check BEFORE `resolveFlowTriggerKind`,
// and that is the ONE deliberate divergence between the two — documented in
// that resolver's own header. It resolves array form to NO kind on purpose:
// reading it as "asks for a record-change trigger" would have `defineStack`
// demand a capability for a flow that can never use it and would widen the
// lint rule's auto-triggered set. The route below is a DIAGNOSTIC route, not
// a trigger the flow could fire on; folding it into the shared resolver would
// delete a standing decision and turn a loud refusal into silence. Pre-check
// ordering also preserves this method's own precedence exactly: array form
// outranks `timeRelative`/`schedule`, which the resolver — blind to it —
// would otherwise answer for a start node carrying both.
if (
Array.isArray(config.triggerType) &&
config.triggerType.some((t) => typeof t === 'string' && (t as string).startsWith('record-'))
Expand All@@ -2493,56 +2508,86 @@ export class AutomationEngine implements IAutomationService {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: config.triggerType.filter((t) => typeof t === 'string').join(','),
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
condition,
config,
},
};
}

// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. Checked BEFORE `schedule`
// because such a flow ALSO carries a `schedule` cadence (the sweep
// interval) — without this precedence it would bind to the plain schedule
// trigger and fire once with no record instead of once per record.
if (config.timeRelative != null && typeof config.timeRelative === 'object') {
const tr = config.timeRelative as Record<string, unknown>;
return {
triggerType: 'time_relative',
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
const kind = resolveFlowTriggerKind(flow);
if (!kind) return undefined;

if (config.schedule != null || flow.type === 'schedule') {
return {
triggerType: 'schedule',
binding: { flowName, schedule: config.schedule, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
switch (kind) {
case 'record_change':
return {
triggerType: kind,
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
// A `record-*` kind means `config.triggerType` IS that string
// token — the resolver read it to answer; the narrowing is
// re-stated because the answer does not carry it back.
event: typeof config.triggerType === 'string' ? config.triggerType : undefined,
condition,
config,
},
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
if (flow.type === 'api' || triggerType === 'api') {
return {
triggerType: 'api',
binding: { flowName, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
// Declarative time-relative sweep (#1874): a start node carrying a
// `timeRelative` descriptor is swept on a schedule and launched once per
// record whose date field falls in the window. The resolver ranks it
// BEFORE `schedule` for the same reason this method did — such a flow
// ALSO carries a `schedule` cadence (the sweep interval), and without
// that precedence it would bind to the plain schedule trigger and fire
// once with no record instead of once per record.
case 'time_relative': {
const tr = (config.timeRelative ?? {}) as Record<string, unknown>;
return {
triggerType: kind,
binding: {
flowName,
object:
typeof tr.object === 'string'
? tr.object
: typeof config.objectName === 'string'
? config.objectName
: undefined,
schedule: config.schedule,
condition,
config,
},
};
}

case 'schedule':
return {
triggerType: kind,
binding: { flowName, schedule: config.schedule, condition, config },
};

// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
case 'api':
return {
triggerType: kind,
binding: { flowName, condition, config },
};

return undefined;
default: {
// [#14328] Exhaustive over `FlowTriggerKind`, and that is the point:
// a kind added to the spec resolver makes `kind` no longer `never`
// here, so THIS package's type-check fails until the binding shape
// for it is written — the drift is caught at the commit that opens
// it instead of showing up as a flow that arms nowhere. At run time
// (a spec build ahead of this one) fall back to today's behaviour —
// no binding — rather than throwing inside the boot audit.
const unhandledKind: never = kind;
void unhandledKind;
return undefined;
}
}
}

/**
Expand Down
Loading
Loading