diff --git a/.changeset/20215-generate-scaffolds-reach-stack.md b/.changeset/20215-generate-scaffolds-reach-stack.md index 984d9b48b90..c0f21a22e0d 100644 --- a/.changeset/20215-generate-scaffolds-reach-stack.md +++ b/.changeset/20215-generate-scaffolds-reach-stack.md @@ -10,12 +10,12 @@ fix(cli): what `os generate` writes now reaches the stack, or the command says i - `objectstack.config.ts` imports every directory `os generate` writes into (`src/objects`, `src/views`, `src/actions`, `src/flows`, `src/dashboards`, `src/apps`, `src/skills`) and hands each barrel's exports to `defineStack` under its key (`objects`, `views`, …). A file `os g` writes there is part of the stack with no edit to the config. The keys read the barrels through a small `exportsOf` helper declared in the config, because `Object.values` on an empty barrel does not type-check against `defineStack`'s collection types. - An `index.ts` containing only `export {};` for each directory the template puts nothing in. An `index.ts` that already exists is kept as it is and never overwritten. -- `requires: ['automation', 'triggers']`. A flow that starts on a record change is fired by `triggers` and run by `automation`. Without `triggers`, `defineStack` refuses the config as soon as it holds such a flow. Without `automation`, the server loads the flow and never runs it. +- `requires: ['automation', 'triggers']`. A flow that starts on a record change is fired by `triggers` and run by `automation`. If either one is missing, `defineStack` refuses the config as soon as it holds such a flow. **What `os generate` now does:** -- After writing, it loads the project's config again and reports on the new item. Either the stack carries it, or it is **not wired** (the file is written, the config is left untouched, and the command prints the import and `defineStack` key to add), or it **cannot run** (a flow in a stack whose `requires` lacks `automation`: the command prints the whole `requires` list to use). It never edits the config. -- It refuses a write that makes a config that loaded stop loading, for example an action or app bound to an object nobody declared, or a flow in a stack without `triggers`. It removes what it wrote, exits 1, and prints the stack's own reason. Generate the object first (`os g object customer`), then what binds to it. `dashboard` and `skill` now read the config too, so they can report, and they still generate when the config does not load. +- After writing, it loads the project's config again and reports on the new item. Either the stack carries it, or it is **not wired** (the file is written, the config is left untouched, and the command prints the import and `defineStack` key to add). It never edits the config. +- It refuses a write that makes a config that loaded stop loading, for example an action or app bound to an object nobody declared, or a flow in a stack without `triggers` or without `automation`. It removes what it wrote, exits 1, and prints the stack's own reason. Generate the object first (`os g object customer`), then what binds to it. `dashboard` and `skill` now read the config too, so they can report, and they still generate when the config does not load. - A view's own `name` is now the object it binds to, prefix included (`my_app_order_line`, not `order_line`). The server registers a view under its object and refused, at boot, a scaffold whose `name` disagreed. That never showed while the views barrel was not loaded. - The barrel step asks the compiler whether the barrel already exports the name, instead of searching the file's text. `os g view order` after `os g view order_line` had found `order` inside `orderLine` and exported nothing. - The `flow` scaffold's header states the `requires` it needs. diff --git a/.changeset/20332-triggers-require-automation.md b/.changeset/20332-triggers-require-automation.md new file mode 100644 index 00000000000..66bae8a8532 --- /dev/null +++ b/.changeset/20332-triggers-require-automation.md @@ -0,0 +1,51 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec): `defineStack` refuses an auto-launched flow whose stack declares `triggers` without `automation` — the pair installs the trigger, `triggers` alone installs nothing (#20332) + +Clause-②: no (narrowing) + +**BREAKING** — shipped as `minor` under the launch-window convention +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by +this banner and the ADR-0087 disposition below, never by the level). + +**What is refused now.** A stack whose `requires` includes `'triggers'` but not +`'automation'`, and which declares a `record_change`, `schedule`, +`time_relative` or `api` flow, used to pass `defineStack` and `os validate`, +boot, and never fire the flow. Every trigger plugin installs its trigger into +the automation service when the kernel is ready, and without that service it +logs `automation service not available — … trigger NOT installed` and installs +nothing. No runtime resolves `triggers` into `automation`. `defineStack` now +refuses that stack with the same `STACK_TRIGGER_CAPABILITY_REQUIRED` code +(`status: 422`), one finding per flow: + +```text +flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' — 'triggers' installs the 'record_change' trigger into the automation service, and without it no 'record_change' trigger would be registered, so the flow would never auto-launch. Add 'automation' to requires: ['automation', 'triggers'] (@objectstack/service-automation runs the flow; @objectstack/trigger-* only fires it). +``` + +**The fix is the one the message names:** add `'automation'` to `requires`, so +it reads `requires: ['automation', 'triggers']`. Nothing is renamed or removed. + +**Also changed: the message for a stack that declares neither token.** An empty +or absent `requires` with such a flow was told to add `requires: ['triggers']`, +which would now be refused a second time. It is told to add both: + +```text +flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' or 'triggers' — no 'record_change' trigger would be registered, so the flow would never auto-launch. Add requires: ['automation', 'triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-* and install into @objectstack/service-automation — 'triggers' alone installs nothing). +``` + +Unchanged: `requires: ['automation']` with such a flow keeps the message it has +always had (add `'triggers'`), word for word. `requires: ['automation', +'triggers']` is accepted, in any order. A stack with no auto-launched flow +(none at all, a `screen` flow, or an `autolaunched` flow started by hand) owes +neither token, and `obsolete` / `invalid` flows are still skipped. The refusal +code, the message header and the `issues` shape are the same, and no export is +added. + +In-tree producers measured: `examples/app-showcase` and `examples/app-todo` +already declare both tokens; `examples/app-crm` and the `create-objectstack` +`blank` template declare `automation` without `triggers` and no auto-launched +flow, so they are untouched. + + diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 074b4657f05..1f439c28c12 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1769,15 +1769,19 @@ export default defineStack({ ``` Registration is not arming. A `record_change`, `schedule`, `time_relative` or -`api` flow fires only when its trigger is mounted, and the triggers ship -separately (`@objectstack/trigger-*`) behind **one** capability token on the -same stack: `requires: ['triggers']` (`automation` mounts the engine itself; -neither is in the always-on slate an absent `requires` falls back to). -`defineStack` refuses a stack that declares such a flow without the token, -naming the flow, the trigger kind it resolved and the fix — because the -alternative was measured: the flow registers, `os validate` and `os build` -pass, and it never runs. A `screen` flow, or an `autolaunched` one you start -by hand, owes nothing. +`api` flow fires only when its trigger is installed, and that takes **both** +capability tokens on the same stack: `requires: ['automation', 'triggers']`. +The triggers ship separately (`@objectstack/trigger-*`, mounted by +`triggers`), and each one installs itself into the automation engine +(`@objectstack/service-automation`, mounted by `automation`) — with +`triggers` alone the trigger plugins load, find no engine, and install +nothing. Neither token implies the other, and neither is in the always-on +slate an absent `requires` falls back to. `defineStack` refuses a stack that +declares such a flow while `requires` lacks either token, naming the flow, the +trigger kind it resolved and the fix for the tokens it is missing — because +the alternative was measured: the flow registers, `os validate` and +`os build` pass, and it never runs. A `screen` flow, or an `autolaunched` one +you start by hand, owes nothing. The plugin is a **soft dependency** on `metadata` — it tolerates running without `MetadataPlugin` and it logs (not throws) on per-flow registration diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 247bfeadca4..3d0531e0b7d 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1425,15 +1425,14 @@ After writing, `os g` loads the config again and says which of these holds: `npm create objectstack` starter do. The file is written, the config is left as it was, and the command prints the import and the `defineStack` key that wire the directory. -- **Cannot run**: the stack carries a flow, and its `requires` lacks - `automation`. The config loads and the server never runs the flow. The - command prints the whole `requires` list to use. - **Refused**: the config loaded before the command wrote anything and no longer loads with the new file in place, because the stack refuses it. Two examples are an action or app bound to an object nobody declared, and a flow - in a stack without `triggers`. The command removes what it wrote, so the - project is as it was, and exits 1 with the stack's own reason. Generate the - object first (`os g object customer`), then what binds to it. + in a stack whose `requires` lacks `triggers` or `automation` (`defineStack` + refuses a record-change flow without both). The command removes what it + wrote, so the project is as it was, and exits 1 with the stack's own reason. + Generate the object first (`os g object customer`), then what binds to it, + and declare `requires: ['automation', 'triggers']` before `os g flow`. `os g` never edits `objectstack.config.ts`: the config is yours, and the command only loads it. diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx index 71cfbee4045..b205cab3bca 100644 --- a/content/docs/permissions/capabilities.mdx +++ b/content/docs/permissions/capabilities.mdx @@ -26,7 +26,7 @@ Read the next section before you write either. | **Vocabulary** | Author-chosen names, `^[a-z][a-z0-9_.]*$` — `export_data`, `billing.refund` | A **closed** vocabulary: canonical kebab-case tokens from `PLATFORM_CAPABILITY_TOKENS` — `ai`, `automation`, `hierarchy-security` | | **Entry shape** | `defineCapability({ name, label, description, scope })` (`CapabilityDeclarationSchema`) | A plain `string` | | **Unknown value** | There is no "unknown" — you are minting the name | A `defineStack` **error** at authoring time (a typo, or a token no runtime provides) | -| **Needed but undeclared** | Nothing to detect — a name is minted here, then granted | A `defineStack` **error** too: a hierarchy scope (`unit` / `unit_and_below` / `own_and_reports`) needs `hierarchy-security`, and a `record_change` / `schedule` / `time_relative` / `api` flow needs `triggers` — without them the runtime fails closed (owner-only visibility) or, for flows, silently never fires. ⚠️ `triggers` is a *capability*, not the whole declaration: a `schedule` / `time_relative` flow also [declares the organization it runs as](/docs/automation/flows#the-acting-organization), and one that does not is refused at bind rather than fired org-less | +| **Needed but undeclared** | Nothing to detect — a name is minted here, then granted | A `defineStack` **error** too: a hierarchy scope (`unit` / `unit_and_below` / `own_and_reports`) needs `hierarchy-security`, and a `record_change` / `schedule` / `time_relative` / `api` flow needs `triggers` and `automation` (the triggers install into the automation engine) — without them the runtime fails closed (owner-only visibility) or, for flows, silently never fires. ⚠️ `triggers` is a *capability*, not the whole declaration: a `schedule` / `time_relative` flow also [declares the organization it runs as](/docs/automation/flows#the-acting-organization), and one that does not is refused at bind rather than fired org-less | | **Consumed by** | `systemPermissions` (grant) and `requiredPermissions` (requirement), by name string | The runtime capability loader, which resolves each token to a service plugin | | **When it bites** | Never at boot — an ungranted capability is simply held by nobody | **Fail-fast at startup**: a declared-but-missing provider aborts boot instead of degrading silently | | **Spec** | ADR-0066 D1 | Platform service vocabulary — see the [CLI reference](/docs/deployment/cli) | diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 3a92cfa433d..9490ed958dc 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -348,12 +348,13 @@ export default ${toCamelCase(name)}Action; * * It declares what it needs to run (#20215): {@link FLOW_SCAFFOLD_REQUIRES}. * `defineStack` refuses a record-change flow in a stack whose `requires` - * lacks `triggers`, and a stack that has `triggers` but not `automation` - * loads it and never runs it — measured on `os serve`: "1 flow(s) declared - * but the automation engine is not enabled — they will never run", each - * trigger plugin "NOT installed". So both tokens are declared here, `os - * init` declares the union, `os g flow` names any the stack is missing, and - * the emitted file says so in its own header. + * lacks `triggers` or `automation` (#20332): the trigger installs into the + * automation service, so a stack with `triggers` alone used to load the flow + * and never run it — measured on `os serve`: "1 flow(s) declared but the + * automation engine is not enabled — they will never run", each trigger + * plugin "NOT installed" — and is now refused instead. So both tokens are + * declared here, `os init` declares the union, `os g flow` names any the + * stack is missing, and the emitted file says so in its own header. */ namesObject: true, itemName: (name: string) => `${toSnakeCase(name)}_flow`, @@ -365,8 +366,8 @@ export default ${toCamelCase(name)}Action; * * Starts when a record changes, so the stack that carries it must declare * requires: [${FLOW_SCAFFOLD_REQUIRES.map((t) => `'${t}'`).join(', ')}]. The 'triggers' capability - * fires the flow and 'automation' runs it: without 'triggers' the config does - * not load, and without 'automation' the server loads the flow and never runs it. + * fires the flow and 'automation' runs it: without either one the config does + * not load. */ const ${toCamelCase(name)}Flow: Automation.Flow = { name: '${toSnakeCase(name)}_flow', diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 3770e16c691..388997adbd4 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -605,9 +605,10 @@ export const SCAFFOLD_WIRED_BARRELS: readonly { type: string; dir: string; stack /** * The union of the capability tokens the scaffolds need to run — today the * `flow` scaffold's pair. Declared by every template that wires the `flows` - * barrel: without `triggers` a record-change flow makes `defineStack` refuse - * the config, so the first `os g flow` would break the project, and without - * `automation` the server loads the flow and never runs it. + * barrel: without `triggers` or without `automation` a record-change flow + * makes `defineStack` refuse the config (#20332: the trigger installs into the + * automation service, so neither token alone installs it), and the first + * `os g flow` would break the project. */ export const SCAFFOLD_WIRED_REQUIRES: readonly string[] = [ ...new Set(GENERATOR_SCAFFOLD_TARGETS.flatMap((t) => t.requires)), @@ -647,9 +648,8 @@ function renderWiredStackKeys(): string { return [ ` // What the files \`objectstack generate\` writes need in order to run. A`, ` // flow that starts on a record change is fired by 'triggers' and run by`, - ` // 'automation': without 'triggers' this config stops loading once it holds`, - ` // such a flow, and without 'automation' the server loads the flow and never`, - ` // runs it. Both can go if this project will never hold a flow.`, + ` // 'automation': without either one this config stops loading once it`, + ` // holds such a flow. Both can go if this project will never hold a flow.`, ` requires: [${requires}],`, '', ` // Every directory \`objectstack generate\` writes into is wired here: its`, diff --git a/packages/cli/test/generate-object-namespace-prefix.test.ts b/packages/cli/test/generate-object-namespace-prefix.test.ts index ca71e78d3f9..f4fef7e9ab2 100644 --- a/packages/cli/test/generate-object-namespace-prefix.test.ts +++ b/packages/cli/test/generate-object-namespace-prefix.test.ts @@ -102,10 +102,11 @@ async function generateAll(name: string, namespace?: string): Promise>, namespace?: string) { const stack: Record = { @@ -116,7 +117,7 @@ function composedStack(artifacts: Record>, names type: 'app', ...(namespace ? { namespace } : {}), }, - requires: ['triggers'], + requires: ['automation', 'triggers'], }; for (const [type, artifact] of Object.entries(artifacts)) { stack[singularToPlural(type)] = [artifact]; diff --git a/packages/cli/test/generate-stack-reach.test.ts b/packages/cli/test/generate-stack-reach.test.ts index 0373db489a4..0ccc9ccc85e 100644 --- a/packages/cli/test/generate-stack-reach.test.ts +++ b/packages/cli/test/generate-stack-reach.test.ts @@ -15,10 +15,15 @@ * exit 0, the config byte-identical, and the exact import and * key lines that wire it printed. * cannot run reached, but the stack's `requires` lacks a token the - * scaffold runs on: the WHOLE requires list printed. + * scaffold runs on: the WHOLE requires list printed. No + * scaffold reaches it through this command since #20332: + * the flow scaffold's only tokens are the pair, and + * `defineStack` refuses a record-change flow in a stack that + * lacks EITHER one, so that stack is `refused` below. * refused a config that loaded before the write and does not load * after it — an action bound to an object nobody declared, a - * flow in a stack without `triggers`: exit 1, and the project + * flow in a stack without `triggers`, a flow in a stack with + * `triggers` but not `automation`: exit 1, and the project * tree byte-identical, because the write is taken back out. * * A control keeps the refusal from being a command that refuses everything: @@ -138,6 +143,7 @@ const runs: Record = {}; let wiredAfterRefusal: Record; let actionFileAfterRefusal: boolean; let noRequiresAfterRefusal: Record; +let triggersOnlyAfterRefusal: Record; beforeAll(async () => { root = mkdtempSync(join(HERE, '..', 'node_modules', '.generate-stack-reach-')); @@ -156,6 +162,7 @@ beforeAll(async () => { before.wired = tree(dirs.wired); before.noRequires = tree(dirs.noRequires); + before.triggersOnly = tree(dirs.triggersOnly); before.preFixConfig = { [CONFIG]: readFileSync(join(dirs.preFix, CONFIG), 'utf-8') }; // Sequential on purpose: cold tsx starts in a container several agents share. @@ -175,6 +182,7 @@ beforeAll(async () => { runs.dashboardPort = await runCli(['g', 'dashboard', 'port'], dirs.wired); runs.flowTriggersOnly = await runCli(['g', 'flow', 'order_line'], dirs.triggersOnly); + triggersOnlyAfterRefusal = tree(dirs.triggersOnly); runs.viewPreFix = await runCli(['g', 'view', 'order_line'], dirs.preFix); runs.viewBare = await runCli(['g', 'view', 'order_line'], dirs.bare); }, RUN_TIMEOUT_MS); @@ -201,6 +209,20 @@ describe('[#20215] refused: the write would stop a loading config from loading', expect(runs.flowNoRequires.stdout).toContain("requires: ['automation', 'triggers']"); }); + // [#20332] This stack was the `cannot run` answer — the config loaded and the + // server never ran the flow. `defineStack` now refuses `triggers` without + // `automation`, so the write stops the config from loading and is refused. + it('a flow in a stack that requires `triggers` but not `automation`: exit 1, the tree byte-identical', () => { + expect(runs.flowTriggersOnly.code, out(runs.flowTriggersOnly)).toBe(1); + expect(triggersOnlyAfterRefusal).toEqual(before.triggersOnly); + expect(existsSync(join(dirs.triggersOnly, 'src', 'flows', 'order_line.flow.ts'))).toBe(false); + // The subject is named: the token the stack lacks, in the stack's own reason. + expect(runs.flowTriggersOnly.stdout).toContain("does not include 'automation'"); + // The line an author pastes: the whole pair. + expect(runs.flowTriggersOnly.stdout).toContain("requires: ['automation', 'triggers']"); + expect(runs.flowTriggersOnly.stdout).not.toContain('Created'); + }); + it('CONTROL: in the same project, once the object exists, the same action generates and reaches', () => { expect(runs.objectControl.code, out(runs.objectControl)).toBe(0); expect(runs.actionControl.code, out(runs.actionControl)).toBe(0); @@ -238,12 +260,3 @@ describe('[#20215] not wired: exit 0, the config untouched, and the lines that w }); }); -describe('[#20215] cannot run: reached, and a capability it runs on is missing', () => { - it('a flow in a stack that requires `triggers` but not `automation`', () => { - expect(runs.flowTriggersOnly.code, out(runs.flowTriggersOnly)).toBe(0); - expect(runs.flowTriggersOnly.stdout).toContain("'order_line_flow'"); - // The whole list, never a second `requires` key beside the first. - expect(runs.flowTriggersOnly.stdout).toContain("requires: ['triggers', 'automation'],"); - expect(runs.flowTriggersOnly.stdout).not.toContain('import * as flows'); - }); -}); diff --git a/packages/lint/src/authoring-rule-input-tier.test.ts b/packages/lint/src/authoring-rule-input-tier.test.ts index 5eee3b9bb30..f29e73e9a7c 100644 --- a/packages/lint/src/authoring-rule-input-tier.test.ts +++ b/packages/lint/src/authoring-rule-input-tier.test.ts @@ -88,9 +88,10 @@ describe('the mechanism: for a defineStack config the `normalized` tier is POST- const flowStack = { manifest, // A `schedule` flow auto-launches, and `defineStack` refuses one whose stack - // does not declare the trigger capability (#14153) — the flow here is only - // the vehicle for a parse-time default, so declare the token it owes. - requires: ['triggers'], + // does not declare the pair that installs its trigger (#14153, #20332) — + // the flow here is only the vehicle for a parse-time default, so declare + // the tokens it owes. + requires: ['automation', 'triggers'], flows: [ { name: 'tier_flow', diff --git a/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts b/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts index c93c2902575..fee91231b0f 100644 --- a/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts @@ -90,8 +90,12 @@ export const overrideCompositeStack = defineStack({ description: 'One object, one flow, one permanently-unstaffed position.', }, // ADR-0097: a `record_change` trigger (the flow's `record-after-create` - // start node) only registers when the app declares it needs the capability. - requires: ['triggers'], + // start node) only registers when the app declares it needs the capability + // — the PAIR, since the trigger installs into the automation service and + // `defineStack` refuses `triggers` without `automation` (#20332). The pin + // that boots this stack mounts both explicitly (`automation: true` plus the + // trigger plugin); this line is the declaration that matches that boot. + requires: ['automation', 'triggers'], objects: [OverrideCompositeRequest], flows: [OverrideCompositeFlow], }); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index d8156cd87b6..d8f2b58a011 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1332,7 +1332,7 @@ export const ERROR_CODE_LEDGER = { 'STACK_NAMESPACE_PREFIX_INVALID', // an object name lacks the `manifest.namespace` prefix 'STACK_SCHEMA_INVALID', // `ObjectStackDefinitionSchema.safeParse` failed; `issues` carries the zod issues structurally 'STACK_SINGLE_APP_VIOLATION', // an `app` package declares more than one app (ADR-0019 D3) - 'STACK_TRIGGER_CAPABILITY_REQUIRED', // an auto-launched flow while `requires` omits `triggers` + 'STACK_TRIGGER_CAPABILITY_REQUIRED', // an auto-launched flow while `requires` omits `triggers` or `automation` (the pair installs its trigger) // [#16348] The COMPOSITION half of the same family, and `door: 'none'` on // the same reading: the six `composeStacks` refusals, one code per raise // site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the diff --git a/packages/spec/src/automation/flow-trigger-kind.ts b/packages/spec/src/automation/flow-trigger-kind.ts index 2e7c8137460..5b419979df7 100644 --- a/packages/spec/src/automation/flow-trigger-kind.ts +++ b/packages/spec/src/automation/flow-trigger-kind.ts @@ -18,9 +18,11 @@ * question "does this flow auto-launch?": * * - `defineStack` refuses a stack whose flow resolves to a kind while - * `requires` omits `'triggers'` — the single token that installs every one - * of these triggers (`PLATFORM_CAPABILITY_PROVIDERS.triggers`). Without it - * the flow registers, validates, builds, and never fires. + * `requires` omits `'triggers'` or `'automation'` — the pair that installs + * every one of these triggers: `triggers` mounts the trigger plugins + * (`PLATFORM_CAPABILITY_PROVIDERS.triggers`) and each installs its trigger + * into the automation service `automation` mounts. Without either the flow + * registers, validates, builds, and never fires. * - `@objectstack/lint`'s `validate-flow-trigger-readiness` reads it as the * auto-triggered predicate behind its draft-status rule. * diff --git a/packages/spec/src/stack-refusal-envelopes.test.ts b/packages/spec/src/stack-refusal-envelopes.test.ts index 95a83a6d993..ace7ac794af 100644 --- a/packages/spec/src/stack-refusal-envelopes.test.ts +++ b/packages/spec/src/stack-refusal-envelopes.test.ts @@ -68,7 +68,7 @@ const app = (name: string) => ({ navigation: [{ id: `nav_${name}`, type: 'object' as const, label: 'Tasks', objectName: task.name }], }); -/** A `record_change` flow — auto-launched, so it owes `requires: ['triggers']`. */ +/** A `record_change` flow — auto-launched, so it owes `requires: ['automation', 'triggers']`, the pair. */ const recordFlow = { name: 'task_fanout', label: 'task_fanout', diff --git a/packages/spec/src/stack-requires.test.ts b/packages/spec/src/stack-requires.test.ts index 5c19969816b..d83c7fa8cfe 100644 --- a/packages/spec/src/stack-requires.test.ts +++ b/packages/spec/src/stack-requires.test.ts @@ -49,7 +49,10 @@ describe('defineStack requires validation (#3265/#3308)', () => { // #14153 — `defineStack` refuses an auto-launched flow (record_change / // schedule / time_relative / api) whose stack does not declare -// `requires: ['triggers']`, the one token that installs those triggers. The +// `requires: ['triggers']`. Since #20332 that is one arm of two: the triggers +// install INTO the automation service, so the pair +// `requires: ['automation', 'triggers']` is what installs them, and the +// `triggers`-without-`automation` arm is pinned in its own block below. The // sibling `validateHierarchyScopeCapability` already hard-errors the same // declared-capability class for hierarchy scopes (which fail CLOSED); this one // covers the class that fails SILENT — the flow registers, validates, builds, @@ -200,3 +203,181 @@ describe('defineStack trigger capability validation (#14153)', () => { expect(stack.flows?.length).toBe(1); }); }); + +// #20332 — the trigger is installed by the PAIR `requires: ['automation', +// 'triggers']`, never by `triggers` alone. Every trigger plugin +// (`RecordChangeTriggerPlugin`, `ScheduleTriggerPlugin`, +// `TimeRelativeTriggerPlugin`, `ApiTriggerPlugin`) registers its trigger INTO +// the automation service at `kernel:ready` and, without that service, warns +// `automation service not available — … trigger NOT installed` and returns; +// no runtime's resolver has `triggers` pull `automation` in. So a stack with +// `triggers` and no `automation` validated, booted, and never fired its flow. +// Refused here, on the same code as the `triggers` arm, with the prescription +// to add `automation`; a stack that declares NEITHER is told to add both, so +// one reading of the refusal is always enough. +// +// Pinned table-driven over requires × flow kind × flow status, each refusal as +// its ADR-0112 envelope (`code`, `status`, one `issues` entry per flow) plus +// the prescription text an author follows. The last block applies each +// prescription literally and asserts the result is ACCEPTED — the property +// the neither-arm exists for. + +describe('defineStack trigger capability — the pair: `triggers` without `automation` (#20332)', () => { + type Envelope = Error & { code?: string; status?: number; issues?: readonly string[] }; + const refusalOf = (stack: Record): Envelope | null => { + try { + defineStack(stack as never); + return null; + } catch (e) { + return e as Envelope; + } + }; + const node = (id: string, type: string, config?: Record) => ({ + id, + type, + label: id, + ...(config ? { config } : {}), + }); + const flow = (name: string, type: string, config?: Record, extra: Record = {}) => ({ + name, + label: name, + type, + nodes: [node('start', 'start', config), node('end', 'end')], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...extra, + }); + const task = { name: 'task', label: 'Task', fields: { title: { type: 'text', label: 'Title' } } }; + + /** One flow per kind `resolveFlowTriggerKind` answers — all four need the automation service. */ + const KIND_FLOWS: Array<[string, Record]> = [ + ['record_change', flow('task_fanout', 'record_change', { objectName: 'task', triggerType: 'record-after-create' })], + ['schedule', flow('daily_digest', 'schedule', { schedule: '0 8 * * *' })], + ['time_relative', flow('renewal_alert', 'schedule', { + timeRelative: { object: 'task', dateField: 'due_date', withinDays: 7 }, + })], + ['api', flow('inbound_hook', 'api')], + ]; + + const HEADER = /^defineStack trigger capability validation failed \(1 issue\):/; + const AUTOMATION_ONLY_FIX = "Add 'automation' to requires: ['automation', 'triggers']"; + const PAIR_FIX = "Add requires: ['automation', 'triggers']"; + const TRIGGERS_ONLY_FIX = "Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)"; + + describe("`requires: ['triggers']` alone is REFUSED for every auto-launched kind, with the prescription to add `automation`", () => { + it.each(KIND_FLOWS)('%s', (kind, f) => { + const refused = refusalOf({ requires: ['triggers'], objects: [task], flows: [f] }); + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBe('STACK_TRIGGER_CAPABILITY_REQUIRED'); + expect(refused?.status).toBe(422); + expect(refused?.message).toMatch(HEADER); + expect(refused?.issues).toHaveLength(1); + const finding = refused?.issues?.[0] ?? ''; + expect(finding).toContain( + `flow '${f.name}' declares a '${kind}' trigger but \`requires\` does not include 'automation' — `, + ); + expect(finding).toContain(`'triggers' installs the '${kind}' trigger into the automation service`); + expect(finding).toContain(AUTOMATION_ONLY_FIX); + // The finding rides the message too, one `✗` line per flow. + expect(refused?.message).toContain(`✗ ${finding}`); + }); + }); + + it("`requires: ['automation', 'triggers']` is ACCEPTED for every kind — the control", () => { + for (const [, f] of KIND_FLOWS) { + expect(refusalOf({ requires: ['automation', 'triggers'], objects: [task], flows: [f] }), String(f.name)).toBeNull(); + } + // Order is not the contract: the pair in either order, among other tokens. + expect(refusalOf({ requires: ['triggers', 'job', 'automation'], objects: [task], flows: [KIND_FLOWS[0][1]] })).toBeNull(); + }); + + it("`requires: []` and an ABSENT `requires` name BOTH tokens — never `['triggers']` alone, which the arm above refuses", () => { + for (const stack of [ + { requires: [], objects: [task], flows: [KIND_FLOWS[0][1]] }, + { objects: [task], flows: [KIND_FLOWS[0][1]] }, + ]) { + const refused = refusalOf(stack); + expect(refused?.code).toBe('STACK_TRIGGER_CAPABILITY_REQUIRED'); + expect(refused?.status).toBe(422); + expect(refused?.message).toMatch(HEADER); + expect(refused?.issues).toHaveLength(1); + const finding = refused?.issues?.[0] ?? ''; + expect(finding).toContain( + "flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' or 'triggers' — ", + ); + expect(finding).toContain(PAIR_FIX); + expect(finding).not.toContain("Add requires: ['triggers']"); + } + }); + + it("`requires: ['automation']` keeps the `triggers` message this refusal has always given, byte-for-byte", () => { + const refused = refusalOf({ requires: ['automation'], objects: [task], flows: [KIND_FLOWS[0][1]] }); + expect(refused?.code).toBe('STACK_TRIGGER_CAPABILITY_REQUIRED'); + expect(refused?.status).toBe(422); + expect(refused?.issues).toEqual([ + "flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'triggers' — " + + "no 'record_change' trigger would be registered, so the flow would never auto-launch. " + + `${TRIGGERS_ONLY_FIX}.`, + ]); + }); + + it("`requires: ['triggers']` with NO auto-launched flow is unaffected — no flows, a screen flow, a hand-launched flow", () => { + const screen = flow('wizard', 'screen', undefined, { + nodes: [node('start', 'start'), node('s1', 'screen', { fields: [] }), node('end', 'end')], + edges: [ + { id: 'e1', source: 'start', target: 's1' }, + { id: 'e2', source: 's1', target: 'end' }, + ], + }); + const manual = flow('by_hand', 'autolaunched', { objectName: 'task' }); + expect(refusalOf({ requires: ['triggers'], objects: [task] })).toBeNull(); + expect(refusalOf({ requires: ['triggers'], objects: [task], flows: [] })).toBeNull(); + expect(refusalOf({ requires: ['triggers'], objects: [task], flows: [screen, manual] })).toBeNull(); + }); + + it("`requires: ['triggers']` with an `obsolete` or `invalid` auto-launched flow is unaffected — the engine never binds those", () => { + for (const status of ['obsolete', 'invalid']) { + const retired = flow('retired', 'record_change', { objectName: 'task', triggerType: 'record-after-create' }, { status }); + expect(refusalOf({ requires: ['triggers'], objects: [task], flows: [retired] }), status).toBeNull(); + } + // …while `draft` and `active` are both armed by the engine, so both are refused. + for (const status of ['draft', 'active']) { + const armed = flow('armed', 'record_change', { objectName: 'task', triggerType: 'record-after-create' }, { status }); + expect(refusalOf({ requires: ['triggers'], objects: [task], flows: [armed] })?.code, status) + .toBe('STACK_TRIGGER_CAPABILITY_REQUIRED'); + } + }); + + it('reports every offending flow together, one `issues` entry per flow', () => { + const refused = refusalOf({ + requires: ['triggers'], + objects: [task], + flows: [...KIND_FLOWS.map(([, f]) => f), flow('by_hand', 'autolaunched')], + }); + expect(refused?.message).toMatch(/^defineStack trigger capability validation failed \(4 issues\):/); + expect(refused?.issues).toHaveLength(4); + for (const [kind, f] of KIND_FLOWS) { + expect(refused?.issues?.some((i) => i.startsWith(`flow '${f.name}' declares a '${kind}' trigger`))).toBe(true); + } + expect(refused?.message).not.toContain("'by_hand'"); + }); + + describe('each prescription, applied literally once, is ACCEPTED', () => { + const cases: Array<[string, string[] | undefined, string[]]> = [ + ["['triggers'] + 'automation'", ['triggers'], ['automation', 'triggers']], + ["[] + the pair", [], ['automation', 'triggers']], + ['absent + the pair', undefined, ['automation', 'triggers']], + ["['automation'] + 'triggers'", ['automation'], ['automation', 'triggers']], + ]; + it.each(cases)('%s', (_label, requires, fixed) => { + const f = KIND_FLOWS[0][1]; + const before = requires === undefined ? { objects: [task], flows: [f] } : { requires, objects: [task], flows: [f] }; + const refused = refusalOf(before); + expect(refused?.code).toBe('STACK_TRIGGER_CAPABILITY_REQUIRED'); + // The prescription names exactly the tokens the fix adds, and nothing else. + const finding = refused?.issues?.[0] ?? ''; + const named = fixed.filter((t) => !(requires ?? []).includes(t)); + for (const token of named) expect(finding).toContain(`'${token}'`); + expect(refusalOf({ requires: fixed, objects: [task], flows: [f] })).toBeNull(); + }); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 37e688387b5..4c0bfced0f0 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2364,8 +2364,10 @@ class StackHierarchyScopeCapabilityRequiredError extends StackRefusalError { /** * [ADR-0112 · #15963] An auto-launched flow is declared while `requires` omits - * `triggers` — {@link validateTriggerCapability}, the declared-capability class - * that fails SILENT. Same `_REQUIRED` spelling as its hierarchy sibling. + * `triggers`, `automation`, or both — the pair that installs its trigger + * (#20332) — {@link validateTriggerCapability}, the declared-capability class + * that fails SILENT. One code for every arm: which token is missing is in the + * finding, not in the code. Same `_REQUIRED` spelling as its hierarchy sibling. */ class StackTriggerCapabilityRequiredError extends StackRefusalError { readonly code = 'STACK_TRIGGER_CAPABILITY_REQUIRED'; @@ -3257,34 +3259,61 @@ function validateHierarchyScopeCapability(data: unknown): string[] { * Auto-launched flows are an ENFORCED capability class, exactly like the * hierarchy scopes above: the trigger that fires a `record_change` / * `schedule` / `time_relative` / `api` flow ships in `@objectstack/trigger-*` - * and is installed by ONE token, `requires: ['triggers']` - * (`PLATFORM_CAPABILITY_PROVIDERS.triggers`). A stack that declares such a - * flow while `requires` omits the token registers the flow, validates, builds - * — and never fires it. The automation engine's boot audit names it after - * deploy (`declares a '…' trigger but is NOT bound`), and nothing before that. - * That is the fail-SILENT half of the pair: a hierarchy scope without its - * capability fails closed (a user notices the missing rows); an autolaunched - * flow without its trigger fails silent (the automation simply does not - * happen). Refuse it here, at authoring, in the boot audit's own words — one - * vocabulary, moved from post-deploy to author time. - * - * An ABSENT `requires` counts as omitting the token: the CLI reads it as `[]` - * and appends only the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`), + * and is installed by the PAIR `requires: ['automation', 'triggers']`. + * `triggers` mounts the trigger plugins + * (`PLATFORM_CAPABILITY_PROVIDERS.triggers`); each of them installs its + * trigger INTO the automation service at `kernel:ready`, and without that + * service installs nothing — `RecordChangeTriggerPlugin`, + * `ScheduleTriggerPlugin`, `TimeRelativeTriggerPlugin` and `ApiTriggerPlugin` + * each warn `automation service not available — … trigger NOT installed` and + * return. `automation` mounts that service (`@objectstack/service-automation`, + * the engine that runs the flow). Neither token implies the other on any + * runtime's resolver: `os serve` expands neither, and cloud's + * `resolveCapabilityDependencies` pulls `queue` / `job` / `messaging` for + * `triggers`, never `automation`. So all four kinds need both tokens. + * + * A stack that declares such a flow while `requires` omits either token + * registers the flow, validates, builds — and never fires it. The automation + * engine's boot audit names the missing `triggers` after deploy (`declares a + * '…' trigger but is NOT bound`); the missing `automation` is named only by + * the trigger plugin's warn and the CLI banner's `the automation engine is + * not enabled — they will never run`, and nothing before that. That is the + * fail-SILENT half of the pair: a hierarchy scope without its capability fails + * closed (a user notices the missing rows); an autolaunched flow without its + * trigger fails silent (the automation simply does not happen). Refuse it + * here, at authoring, in the boot audit's own words — one vocabulary, moved + * from post-deploy to author time. ⛔ `triggers` never IMPLIES `automation` + * here either: that would switch on a service the author did not name. + * + * One line per offending flow, and its prescription is the WHOLE fix for the + * `requires` it was given, so following it once is enough: + * + * `automation` present, `triggers` missing → add `'triggers'` (the message + * this refusal has always said); + * `triggers` present, `automation` missing → add `'automation'`; + * neither → add both, `['automation', 'triggers']` + * — never `['triggers']` alone, + * which the arm above would refuse. + * + * An ABSENT `requires` counts as omitting both tokens: the CLI reads it as + * `[]` and appends only the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`), * which carries neither `automation` nor `triggers`, so a stack that declares * nothing gets no trigger either (measured on `serve`'s capability resolver). * * Flows whose `status` disables them (`obsolete` / `invalid`) are skipped — * the engine never binds those, its boot audit skips them for the same * reason, and a stack that deliberately retired a triggered flow owes no - * capability for it. The kind is `resolveFlowTriggerKind`, shared with - * `@objectstack/lint`, so the two authoring surfaces cannot disagree on which - * flows auto-launch. + * capability for it. A stack with no auto-launched flow owes neither token. + * The kind is `resolveFlowTriggerKind`, shared with `@objectstack/lint`, so the + * two authoring surfaces cannot disagree on which flows auto-launch. */ function validateTriggerCapability(data: unknown): string[] { const errors: string[] = []; const d = data as { requires?: unknown; flows?: unknown }; const requires = Array.isArray(d?.requires) ? (d.requires as string[]) : []; - if (requires.includes('triggers')) return errors; + const hasTriggers = requires.includes('triggers'); + const hasAutomation = requires.includes('automation'); + if (hasTriggers && hasAutomation) return errors; const flows = Array.isArray(d?.flows) ? (d.flows as unknown[]) : []; for (const flow of flows) { const f = flow as { name?: unknown; status?: unknown } | null; @@ -3292,11 +3321,29 @@ function validateTriggerCapability(data: unknown): string[] { const kind = resolveFlowTriggerKind(flow); if (!kind) continue; const name = typeof f?.name === 'string' ? f.name : '?'; - errors.push( - `flow '${name}' declares a '${kind}' trigger but \`requires\` does not include 'triggers' — ` + - `no '${kind}' trigger would be registered, so the flow would never auto-launch. ` + - `Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*).`, - ); + const subject = `flow '${name}' declares a '${kind}' trigger but \`requires\` does not include`; + if (!hasTriggers && hasAutomation) { + errors.push( + `${subject} 'triggers' — ` + + `no '${kind}' trigger would be registered, so the flow would never auto-launch. ` + + `Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*).`, + ); + } else if (hasTriggers) { + errors.push( + `${subject} 'automation' — ` + + `'triggers' installs the '${kind}' trigger into the automation service, and without it no '${kind}' ` + + `trigger would be registered, so the flow would never auto-launch. ` + + `Add 'automation' to requires: ['automation', 'triggers'] ` + + `(@objectstack/service-automation runs the flow; @objectstack/trigger-* only fires it).`, + ); + } else { + errors.push( + `${subject} 'automation' or 'triggers' — ` + + `no '${kind}' trigger would be registered, so the flow would never auto-launch. ` + + `Add requires: ['automation', 'triggers'] (record_change/schedule/time_relative/api ship in ` + + `@objectstack/trigger-* and install into @objectstack/service-automation — 'triggers' alone installs nothing).`, + ); + } } return errors; }