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
13 changes: 13 additions & 0 deletions .changeset/33019-flow-answer-delegated-dropped-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': patch
'@objectstack/service-automation': patch
---

fix(automation): a flow's answer also names the read-only fields its `map` items and its paused-then-resumed `subflow` children dropped

Clause-②: yes (widening)
Metadata-protocol: none

- **`@objectstack/service-automation`**: the run answer's `droppedFields` (#32861) now rolls up every child run that `summary` credits on completion. A `map` node folds the drop events of each item that completed, on its synchronous exit and on the exit that pauses; a `subflow` or `map` child that paused and then completed hands its drop events to the parent's awaiting step, whether the caller resumed the parent (the delegated leg) or the child's own run id (the up-bubble). This lifts the delegated limit #32861's note states for those two shapes. One fold serves every leg, one event per object and reason as before.
- The fold is now total: a list or event that is not well-typed (a host-registered executor's malformed `droppedFields`) is skipped, and a completed run is never turned into a failed or retried one over it.
- **`@objectstack/spec`**: the `AutomationResult.droppedFields` jsdoc and the `TriggerFlowResponseSchema.data.droppedFields` describe state the rule as built. What remains: a child run that failed answers no drops, so where a `try_catch` or `fault` edge contains that failure an absent member is not proof the failed child dropped nothing; and a `map` item whose paused run an operator cancelled answers none either (the map goes on with the cancelled marker in that item's `results` slot). The member's shape is unchanged.
2 changes: 1 addition & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1141,7 +1141,7 @@ const result = AutomationApiErrorCode.parse(data);
| **flowLabel** | `string` | optional | The flow definition's authored `label`, copied verbatim so a runner can name the flow (header, completion toast) and translate it against `flows.<flow>.label`. Set on every result of an evaluation of a registered flow (paused and terminal alike); absent on a refusal carrying `code`. For a subflow chain it is the addressed (parent) run's flow. Never defaulted to the API name |
| **refusalMessage** | `string` | optional | Rendered refusal, set when `status` is `refused` - the `end` node's `message` template interpolated against the run's variables, so it names the record. Authored per-record text (not a flow-level copy like the two above); absent on every other status. A runner shows it with Close only |
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | What the run did - records selected / acted on, gate skips, per-node status. Set on a TERMINAL result (a paused run has not finished doing it yet). |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Caller-supplied field values a write step of the run did not store (e.g. a `readonly` field an `update_record` step wrote): the data engine's own drop events, one per object and reason in first-seen order; a field named means at least one write dropped it, not every write. The steps still succeeded and the run's outcome is unchanged - this tells the caller which values never landed. Set on a terminal success only, and only when a field was dropped. Delegated writes: a `subflow` child that completes without pausing rolls its drops up here; a child that paused, and a `map` item's run, do not yet, so an absent member is not proof that a delegated write dropped nothing. |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Caller-supplied field values a write step of the run did not store (e.g. a `readonly` field an `update_record` step wrote): the data engine's own drop events, one per object and reason in first-seen order; a field named means at least one write dropped it, not every write. The steps still succeeded and the run's outcome is unchanged - this tells the caller which values never landed. Set on a terminal success only, and only when a field was dropped. Delegated writes roll up here too: a `subflow` child's run and each `map` item's run, whether it completed without pausing or paused and resumed. A child run that failed answers no drops, so where a `try_catch` or `fault` edge contains that failure an absent member is not proof the failed child dropped nothing. A `map` item whose paused run an operator cancelled answers none either (its slot in `results` holds the cancelled marker). |


---
Expand Down
22 changes: 22 additions & 0 deletions packages/services/service-automation/src/builtin/map-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { MapConfigParsed } from '@objectstack/spec/automation';
import type { AutomationContext } from '@objectstack/spec/contracts';
import type { AutomationEngine } from '../engine.js';
import { refuseNode } from '../guard-refusal.js';
import { foldDropEvents } from '../run-dropped-fields.js';
import { carriedRefusalOfResult } from '../inner-refusal.js';
import { parseNodeConfig } from './parse-config.js';
import { collectionNotAList, resolveCollectionSlot, resolveValueSlotMap } from './value-slot-map.js';
Expand Down Expand Up @@ -206,6 +207,19 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
let failures = 0;
let failuresTracked = false;
const rolledFailures = (): { failures?: number } => (failuresTracked ? { failures } : {});
// #33019 — the drop events of the items that COMPLETED during this
// entry, rolled onto this step beside their totals and through the one
// fold, so the parent's answer (`AutomationResult.droppedFields`, folded
// from its steps) names a value an item's write did not store. Same
// per-entry rule as the totals: a PAUSED item's drops are credited to
// this step by the engine when its run bubbles back
// (AutomationEngine.creditChildRun). A failed or refused item's answer
// carries none.
const itemDrops: unknown[] = [];
const rolledDrops = () => {
const droppedFields = foldDropEvents(itemDrops);
return droppedFields ? { droppedFields } : {};
};

// Drive items in order. Synchronous items advance inline; a pausing item
// suspends the run and is resumed via re-entry.
Expand Down Expand Up @@ -261,6 +275,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
// item's own totals to this same step when its child run bubbles
// back (AutomationEngine.creditChildRun).
metrics: { selected, acted, ...(unmeasured ? { unmeasuredEffect: true } : {}), ...rolledFailures() },
...rolledDrops(),
};
}
if (!child.success) {
Expand All @@ -287,6 +302,10 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
...(unmeasured || child.summary?.unmeasured ? { unmeasuredEffect: true } : {}),
...rolledFailures(),
},
// The items that completed before it keep their drops too: a
// `try_catch` or `fault` edge that contains this failure lets the
// run complete, and its answer folds this step.
...rolledDrops(),
};
}
if (child.status === 'refused') {
Expand Down Expand Up @@ -330,6 +349,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
? { failures: failures + child.summary.failed }
: rolledFailures()),
},
...rolledDrops(),
};
}
// Synchronous completion — record and advance.
Expand All @@ -340,6 +360,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
// One uncountable effect anywhere in the batch makes the batch's
// `acted` incomplete — the flag rides out with this entry's metrics.
if (child.summary?.unmeasured) unmeasured = true;
itemDrops.push(child.droppedFields);
// #15617 — this item went on and contained its failures, so they are
// this run's to answer for. `undefined` is "the child tracked no
// count", so it neither adds nor flips the presence bit.
Expand Down Expand Up @@ -379,6 +400,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
// slot in `results` but is never counted as a run (#27971).
output: { results: state.results, count: state.results.filter(r => !isMapItemCancelledMarker(r)).length },
metrics: { selected, acted, ...(unmeasured ? { unmeasuredEffect: true } : {}), ...rolledFailures() },
...rolledDrops(),
};
},
});
Expand Down
30 changes: 26 additions & 4 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8573,10 +8573,21 @@ export class AutomationEngine implements IAutomationService {
* `!childRes.success` before reaching here — which is what lets #15617's
* `failures` ride this seam under exactly its declared rule: the contained
* failures of a child that COMPLETED, never a failed child's own `failed`.
*
* #33019 — the same seam carries the child's `droppedFields` (its own
* answer's), folded onto that awaiting step through the one fold
* (`foldDropEvents`), so the parent's terminal answer — folded from its
* steps — reports a paused child's or a `map` item's dropped values
* wherever this method credits its totals. A failed child answers none.
*/
private creditChildRun(steps: StepLogEntry[], nodeId: string, child: FlowRunSummary | undefined): void {
private creditChildRun(
steps: StepLogEntry[],
nodeId: string,
child: FlowRunSummary | undefined,
childDroppedFields?: readonly DroppedFieldsEvent[],
): void {
// The body lives in ./run-resume.ts (#25222).
creditChildRun(steps, nodeId, child);
creditChildRun(steps, nodeId, child, childDroppedFields);
}

/**
Expand All @@ -8592,6 +8603,11 @@ export class AutomationEngine implements IAutomationService {
* because it is engine-internal control flow, not data the parent's
* variable map should ever see — the reserved-name guard
* (`applyResumeSignal`) polices that map, and a refusal is not a variable.
* @param childDroppedFields - #33019: the drop events of the child run
* whose completion triggered this resume (the up-bubble path), folded
* onto the awaiting step beside `childSummary`. A parameter for the same
* reason: the parent's answer reads them from its steps, never from its
* variable map.
*/
private resumeInternal(
runId: string,
Expand All @@ -8604,6 +8620,7 @@ export class AutomationEngine implements IAutomationService {
skipBubble: boolean,
childSummary?: FlowRunSummary,
childRefusal?: ChildRunRefusal,
childDroppedFields?: readonly DroppedFieldsEvent[],
): Promise<AutomationResult> {
// The body lives in ./run-resume.ts (#25222).
const host: ThisParameterType<typeof resumeInternal> = {
Expand Down Expand Up @@ -8635,7 +8652,7 @@ export class AutomationEngine implements IAutomationService {
failAncestors: (...a) => this.failAncestors(...a),
renderFlowTerminalMessage: (...a) => this.renderFlowTerminalMessage(...a),
};
return resumeInternal.call(host, runId, signal, skipBubble, childSummary, childRefusal);
return resumeInternal.call(host, runId, signal, skipBubble, childSummary, childRefusal, childDroppedFields);
}

/**
Expand Down Expand Up @@ -8807,6 +8824,11 @@ export class AutomationEngine implements IAutomationService {
* control flow, not a variable.
*/
refusal?: ChildRunRefusal,
/**
* #33019 — this child's drop events (the ones its own terminal answer
* carries), credited to the parent's awaiting step beside `summary`.
*/
droppedFields?: readonly DroppedFieldsEvent[],
): Promise<void> {
const ctx = run.context as Record<string, unknown> | undefined;
const parentRunId = ctx?.$parentRunId;
Expand All @@ -8821,7 +8843,7 @@ export class AutomationEngine implements IAutomationService {
// the one writer allowed to set them (#3853 follow-up).
? engineBuilt({ variables: { [`${mapNode}.$mapItemOutput`]: output ?? null, [`${mapNode}.$mapItemDone`]: true } })
: this.buildSubflowResumeSignal(run.context, output);
const parentRes = await this.resumeInternal(parentRunId, sig, false, summary, refusal);
const parentRes = await this.resumeInternal(parentRunId, sig, false, summary, refusal, droppedFields);
if (!parentRes.success) {
// #6499 — `parentRes.error` is the envelope field that carries
// a failing node's / driver's text VERBATIM (#5912 left it
Expand Down
Loading
Loading