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
2 changes: 1 addition & 1 deletion .changeset/20678-subflow-disable-sequence.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/service-automation': minor
---
Expand All @@ -18,7 +18,7 @@
**Not refused:**

- Enabling a flow that is already enabled. Nothing is re-armed.
- A flow the customer authored, or a subflow the customer authored.
- A subflow the customer authored. A flow the customer authored is not this switch's to enable at all: the activation switch switches packaged flows only, and it refuses a customer-authored flow for that reason before this guard is asked (see the entry "the toggle door refuses a flow no package ships, naming its status switch").
- A subflow in a cycle of switched-off flows with the flow being enabled, including a flow that calls itself. Each flow in such a cycle would refuse the others, so no order could complete. A subflow in such a cycle whose definition's `status` also disables it is still named, with its publish remedy: no enable order changes a status.

The disable direction of the same guard is described in its own entry, "disabling a packaged subflow completes once its packaged callers are switched off and hold no parked run".
7 changes: 7 additions & 0 deletions .changeset/20726-clone-notice-status-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/runtime': patch
---

fix(runtime): the clone door's notice names the clone's own off-switch, its status (#20726)

`POST /api/v1/automation/:name/clone` answers a `notice` saying the clone is armed. It told the admin to switch the clone off through `POST /api/v1/automation/NAME/toggle`. A clone carries no package envelope, so it is a flow authored in the deployment, and that switch refuses it: the switch turns packaged flows on and off only. The notice now names the clone's own switch: send its complete definition with `status: 'obsolete'` to `PUT /api/v1/automation/NAME`. It also says that the toggle switches packaged flows only and refuses the clone, whatever flow the clone was copied from. The response shape is unchanged; only the notice text moves.
7 changes: 7 additions & 0 deletions .changeset/20726-toggle-door-docs-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/client': patch
---

docs(client): `automation.toggle` says it switches packaged flows only, and names a customer flow's switch (#20726)

`client.automation.toggle` had no docblock of its own: its one line had drifted above an unrelated member. It now says that it switches packaged flows only, and that a flow authored in the deployment is refused with 409 `RESOURCE_CONFLICT`. Such a flow's switch is its `status`, sent with the complete definition through `automation.update`. This is prose only: the method's signature and behaviour are unchanged.
7 changes: 7 additions & 0 deletions .changeset/20726-toggle-door-docs-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/spec': patch
---

docs(spec): the Automation API docblock says the toggle door switches packaged flows only, and names a customer flow's switch (#20726)

The module docblock of `api/automation-api.zod.ts` listed `POST /api/v1/automation/:name/toggle` as "Enable/disable flow". That file ships as source, and its docblock is also the source of the Automation API reference page. The line now reads "Enable/disable a packaged flow". A new paragraph says what a flow authored in the deployment uses instead: its `status`, published with the complete definition through `PUT /api/v1/automation/:name`. The toggle door refuses such a flow with 409 `RESOURCE_CONFLICT`. The `IAutomationService.toggleFlow` docblock, which read "Enable or disable a flow", says the same. This is prose only: no schema, type or export changes.
23 changes: 23 additions & 0 deletions .changeset/20726-toggle-door-packaged-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/service-automation': minor
---

fix(service-automation)!: the toggle door refuses a flow no package ships, naming its status switch (#20726)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which flows the activation switch accepts: a flow without package provenance is refused, and its own `status`, which it always had, is its switch. -->

**BREAKING**: shipped as `minor` under the launch-window convention. `toggleFlow(name, enabled)` on the automation service, and so `POST /api/v1/automation/:name/toggle` and `client.automation.toggle`, now switches packaged flows only: a flow a code package ships.

**What was wrong.** The switch records an installation's choice in the packaged-metadata activation ledger (`sys_metadata_activation`, ADR-0126 §7.2), whose rows name the package that ships the flow. For a flow authored in the deployment it wrote a row anyway:

- A flow with no package id, such as one created through `POST /api/v1/automation` or the clone door, was refused with 400 `VALIDATION_FAILED` "Package is required", naming a field the caller never sent.
- A flow carrying the runtime-row package sentinel or an app package id was accepted, and a ledger row was written for it. That gave it a second off-switch beside its own `status`.
- With no ledger attached, the flip was accepted in process only.

**What changed.** A flow without package provenance is now refused with `RESOURCE_CONFLICT` / `409`, in both directions and with or without a ledger. The refusal comes before anything is written or changed. The message says the switch turns packaged flows on and off. It names the flow's own switch: its definition's `status`, published with the complete definition through `PUT /api/v1/automation/:name`. `obsolete` switches it off and `active` arms it. The switch never rewrites a definition itself. Packaged flows toggle exactly as before.

**Migration.** To switch a customer-authored flow off, stop sending `POST /api/v1/automation/NAME/toggle` with `{"enabled": false}`. Instead, send `PUT /api/v1/automation/NAME` with the flow's complete definition and `status: 'obsolete'`, and `status: 'active'` to arm it again. In the SDK, `client.automation.toggle(name, false)` becomes `client.automation.update(name, { ...definition, status: 'obsolete' })`.

**A customer flow that a ledger row already holds off.** If this switch turned a customer flow off before this release, its ledger row still holds the flow off after the upgrade, and no `status` clears that row. The refusal says so and names the step that completes: clone the flow under a new name through `POST /api/v1/automation/NAME/clone`, which arms the copy, then remove the old one.
10 changes: 9 additions & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,22 @@ the former `GET /api/v1/automation` list route, its request/response schemas
and `client.automation.list` are retired (ADR-0087 semantic entry
`automation-flow-list-route-retired`).

The toggle door switches PACKAGED flows only: a flow a code package ships.
It records the installation's choice in the packaged-metadata activation
ledger (ADR-0126 §7.2). A flow authored in the deployment is not switched
there. Its switch is its own `status`: `'obsolete'` disarms it and
`'active'` arms it, published with the complete definition through
`PUT /api/v1/automation/:name`. The toggle door refuses such a flow with
409 `RESOURCE_CONFLICT`, names that switch, and changes nothing.

**Endpoints**
```
GET /api/v1/automation/:name — Get flow
POST /api/v1/automation — Create flow
PUT /api/v1/automation/:name — Update flow
DELETE /api/v1/automation/:name — Delete flow
POST /api/v1/automation/:name/trigger — Trigger flow execution
POST /api/v1/automation/:name/toggle — Enable/disable flow
POST /api/v1/automation/:name/toggle — Enable/disable a packaged flow
GET /api/v1/automation/:name/runs — List execution runs
GET /api/v1/automation/:name/runs/:runId — Get single execution run
```
Expand Down
14 changes: 11 additions & 3 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5483,9 +5483,6 @@ export class ObjectStackClient {
return this.unwrapResponse(res);
},

/**
* Enable or disable a flow
*/
/* [#3563 PR-5] The three descriptor/status routes that had no SDK
* expression — they back the Studio designer's pickers and badges. */

Expand Down Expand Up @@ -5525,6 +5522,17 @@ export class ObjectStackClient {
return this.unwrapResponse(res);
},

/**
* Enable or disable a PACKAGED flow — one a code package ships.
*
* [#20726, ADR-0126 §7.2] `POST /automation/:name/toggle` records the
* installation's choice in the packaged-metadata activation ledger, so
* it switches packaged flows only. A flow authored in the deployment is
* refused with 409 `RESOURCE_CONFLICT` and nothing changes. Its switch
* is its own `status`: send its complete definition through
* `automation.update(name, definition)` (`PUT /automation/:name`) with
* `status: 'obsolete'` to disarm it, or `status: 'active'` to arm it.
*/
toggle: async (name: string, enabled: boolean): Promise<{ name: string; enabled: boolean }> => {
const route = this.getRoute('automation');
const res = await this.fetch(`${this.baseUrl}${route}/${name}/toggle`, {
Expand Down
6 changes: 5 additions & 1 deletion packages/runtime/src/domains/automation-flow-clone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,11 @@ describe('#12156 — the source, the notice, and the gate', () => {
// `obsolete`/`invalid`, so a cloned record-change flow runs beside the
// one it was copied from. Saying so is cheaper than the surprise.
expect(notice).toMatch(/off-switch/i);
expect(notice).toContain('toggle');
// [#20726] And the off-switch it names is one the clone has: its own
// `status`, through its update door. A clone carries no package
// envelope, and the activation toggle switches packaged flows only.
expect(notice).toContain("status: 'obsolete'");
expect(notice).toContain('PUT /api/v1/automation/');
});

it('is an authoring write — a caller without `manage_metadata` is refused 403, nothing registered', async () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,10 @@ function isFlowEnablementWrite(parts: string[], method: string): boolean {
* `POST /` → registerFlow (create)
* `PUT /:name` → registerFlow (update)
* `DELETE /:name` → unregisterFlow (deregister)
* `POST /:name/toggle` → toggleFlow (enablement — commit 266436a7f, see below)
* `POST /:name/toggle` → toggleFlow (enablement — commit 266436a7f, see below;
* PACKAGED flows only, #20726: a customer
* flow's switch is its `status`, via
* `PUT /:name`)
*
* ## [commit 266436a7f] Why `toggle` joins them — ruled, not inferred
*
Expand Down Expand Up @@ -1904,7 +1907,12 @@ export async function classifyResumeResult(
* ran and failed → 400 `FLOW_FAILED`; #9378 + #9415;
* a run that PAUSED → 200 with `runId` / `screen`,
* on whichever attempt it paused — #9510)
* POST /:name/toggle → toggleFlow (unknown name → 404, #7535)
* POST /:name/toggle → toggleFlow (unknown name → 404, #7535). Switches
* PACKAGED flows only — it writes the ADR-0126 §7.2
* activation ledger. A flow no package ships → 409
* `RESOURCE_CONFLICT` naming that flow's own switch,
* its `status` ('obsolete' / 'active') published
* through `PUT /:name`; nothing changes (#20726)
* ⚑ authoring write — `manage_metadata` (commit 266436a7f):
* enablement is environment-wide, so an
* unentitled toggle reached every organization
Expand Down
14 changes: 12 additions & 2 deletions packages/runtime/src/flow-clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,24 @@ export const FLOW_CLONE_DROPPED_KEYS: readonly string[] = Object.freeze([
* record-change flow and walks away has two flows running on one trigger,
* and the only thing standing between them and that surprise is this
* sentence.
*
* [#20726, ADR-0126 §7.2] The off-switch it names is the CLONE's own: its
* `status`, published through `PUT /:name`. A clone carries no package
* envelope (see {@link FLOW_CLONE_DROPPED_KEYS}), so it is a flow authored in
* this deployment, and the activation toggle — which switches packaged flows
* only — refuses it. That holds whatever the clone was copied from: the clone
* door takes any registered flow as its source, packaged or not, so the
* notice makes no claim about the source's provenance.
*/
export const FLOW_CLONE_NOTICE =
'References are not re-pointed: this clone calls exactly what the original called '
+ '(subflows, actions and objects are unchanged). It is created with status `draft`, '
+ 'which is a lifecycle label and NOT an off-switch — a cloned record-change or schedule '
+ 'flow is bound to its trigger and will run alongside the flow it was copied from. '
+ 'Disable it (`POST /api/v1/automation/<name>/toggle` with `{"enabled": false}`) if that '
+ 'is not what you want.';
+ 'If that is not what you want, switch the clone off through its own status: send its '
+ 'complete definition with `status: \'obsolete\'` to `PUT /api/v1/automation/<name>`. '
+ 'The activation toggle (`POST /api/v1/automation/<name>/toggle`) switches packaged flows '
+ 'only and refuses the clone.';

/** ADR-0112 envelope for the same-name refusal: a status AND a code. */
export const FLOW_CLONE_NAME_TAKEN_STATUS = 409;
Expand Down
12 changes: 10 additions & 2 deletions packages/services/service-automation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,15 @@ automation.registerFlow?.('escalate_high_priority_case', escalateCase);

const names = await automation.listFlows(); // string[] of machine names
const parsed = await automation.getFlow?.('escalate_high_priority_case');
await automation.toggleFlow?.('escalate_high_priority_case', false);

// A flow registered here is authored in the deployment, so its switch is its
// own `status`: re-register it with 'obsolete' to switch it off ('active' arms it).
automation.registerFlow?.('escalate_high_priority_case', { ...escalateCase, status: 'obsolete' });

// `toggleFlow` switches PACKAGED flows only — one a code package ships — by
// writing the ADR-0126 activation ledger. It refuses a flow no package ships
// with RESOURCE_CONFLICT / 409 and changes nothing.
await automation.toggleFlow?.('a_packaged_flow', false);
```

`registerFlow` validates against the live action registry and rejects unknown `config`
Expand All @@ -292,7 +300,7 @@ GET /api/v1/automation/:name # get one flow
PUT /api/v1/automation/:name # update a flow
DELETE /api/v1/automation/:name # delete a flow
POST /api/v1/automation/:name/trigger # execute a flow
POST /api/v1/automation/:name/toggle # enable / disable
POST /api/v1/automation/:name/toggle # enable / disable a packaged flow (a customer flow: its status, via PUT)
GET /api/v1/automation/:name/runs # list runs
GET /api/v1/automation/:name/runs/:runId # run detail
GET /api/v1/automation/:name/runs/:runId/screen # screen spec of a parked run
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,8 @@ describe('#6499 sites 5–8 — plugin-supplied code seams, all #4632 FUNCTIONAL
const engine = new AutomationEngine(jsonLogger());
const trigger: FlowTrigger = { type: 'record_change', start() {}, stop() { throw new Error(MULTILINE_DRIVER); } };
engine.registerTrigger(trigger);
engine.registerFlow('rc_unbind', triggeredFlow('rc_unbind'));
// [#20726] Unbound through the toggle, which switches packaged flows only.
engine.registerFlow('rc_unbind', { ...triggeredFlow('rc_unbind'), _packageId: 'crm' });

const streams = await captureBoth(async () => { await engine.toggleFlow('rc_unbind', false); });

Expand Down
24 changes: 17 additions & 7 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1334,9 +1334,16 @@ describe('AutomationEngine - Execution History', () => {
});
});

/**
* [#20726, ADR-0126 §7.2] The toggle switches PACKAGED flows — a flow no
* package ships is refused, and its switch is its `status` — so every
* subject toggled below ships from a code package.
*/
const packagedSimpleFlow = { ...simpleFlow, _packageId: 'crm' };

describe('toggleFlow', () => {
it('should disable a flow', async () => {
engine.registerFlow('test_flow', simpleFlow);
engine.registerFlow('test_flow', packagedSimpleFlow);
await engine.toggleFlow('test_flow', false);

const result = await engine.execute('test_flow');
Expand All @@ -1345,7 +1352,7 @@ describe('AutomationEngine - Execution History', () => {
});

it('should enable a disabled flow', async () => {
engine.registerFlow('test_flow', simpleFlow);
engine.registerFlow('test_flow', packagedSimpleFlow);
await engine.toggleFlow('test_flow', false);
await engine.toggleFlow('test_flow', true);

Expand Down Expand Up @@ -1485,11 +1492,11 @@ describe('AutomationEngine - Execution History', () => {
* wall's stated prohibition.
*/
it('keeps a ledger disable across unregister + re-register (§6 wall 3)', async () => {
engine.registerFlow('test_flow', simpleFlow);
engine.registerFlow('test_flow', packagedSimpleFlow);
await engine.toggleFlow('test_flow', false);
engine.unregisterFlow('test_flow');

engine.registerFlow('test_flow', simpleFlow);
engine.registerFlow('test_flow', packagedSimpleFlow);

const result = await engine.execute('test_flow');
expect(result.success).toBe(false);
Expand Down Expand Up @@ -2920,7 +2927,8 @@ describe('AutomationEngine - Flow Trigger Wiring', () => {
it('stops/restarts the binding when the flow is disabled/re-enabled', async () => {
const rec = recordingTrigger('record_change');
engine.registerTrigger(rec.trigger);
engine.registerFlow('rc_flow', recordChangeFlow('rc_flow'));
// [#20726] The toggle switches packaged flows only.
engine.registerFlow('rc_flow', { ...recordChangeFlow('rc_flow'), _packageId: 'crm' });

await engine.toggleFlow('rc_flow', false);
expect(rec.stopped).toEqual(['rc_flow']);
Expand Down Expand Up @@ -3227,7 +3235,8 @@ describe('#9378 — execute() classifies terminal exits for the trigger transpor
expect(missing.status).toBeUndefined();

// 2. Registered but disabled.
engine.registerFlow('disabled_flow', failingFlow('disabled_flow'));
// [#20726] Disabled through the toggle, which switches packaged flows only.
engine.registerFlow('disabled_flow', { ...failingFlow('disabled_flow'), _packageId: 'crm' });
await engine.toggleFlow('disabled_flow', false);
const disabled = await engine.execute('disabled_flow');
expect(disabled.success).toBe(false);
Expand Down Expand Up @@ -3264,7 +3273,8 @@ describe('#9378 — execute() classifies terminal exits for the trigger transpor
* applied to one copy only is the shape a later reader mistakes for a rule.
*/
it('says WHICH refusal a never-dispatched exit is — code, and still no status', async () => {
engine.registerFlow('disabled_coded', failingFlow('disabled_coded'));
// [#20726] Disabled through the toggle, which switches packaged flows only.
engine.registerFlow('disabled_coded', { ...failingFlow('disabled_coded'), _packageId: 'crm' });
await engine.toggleFlow('disabled_coded', false);
const disabled = await engine.execute('disabled_coded');
expect(disabled.success).toBe(false);
Expand Down
Loading
Loading