Skip to content

Commit 14bd30f

Browse files
authored
fix(flow): resume reads the live subject, routed items reach their branch, portal rows carry case context, and one bad flow cannot silence an event (#3310)
* fix(flow): a resumed run reads the subject as it stands, not the trigger-time snapshot A run's items begin as a snapshot of the subject, and resume replayed that snapshot verbatim: the applicant supplied the missing description and completed the task, and the re-check still read description: null off the frozen item — the shipped re-ask loop could never succeed and the case stranded. The live subject was already resolved on every resume path and then used only as the marking holder. Now both resume paths (worker execute() and the sync advanceStream()) refresh the subject's own fields on every stored item that IS the subject, matched by identity, on the flat items list and the per-place buffers alike. Live wins on the subject's keys; step-produced keys survive, so flow-runs REQ-FR-003 (items survive the pause) still holds — the refresh never re-seeds and never touches an item carrying another object's identity. Regression: FlowRunServiceTest::testAResumedRunBranchesOnTheLiveSubjectNotTheTriggerTimeSnapshot and ::testAReAskLoopThatReceivesTheAnswerTerminates — both red before this change (the loop one strands at the transition ceiling, the field symptom). * fix(flow): a routing tag names an exit; resolve it to the exit's place before delivery The two halves of per-item routing spoke different vocabularies. The route node tags each item with the output its rule matched — an EXIT id ('no') — while FlowItemPlacement::itemsForOutput() matches tags against PLACE names ('onRejected'). Unresolved, every routed item was discarded as the token landed: the branch fired with zero items, SetFields iterated over nothing, and the run persisted items: [], losing the seed and the earlier steps' output. That is the sync task-completion repro (advance: "all") where ask, route and onRejected all fired correctly and the items were empty afterwards — and it would strand identically under the worker; the sync path was just the only one an e2e drove through a router. FlowTokenRouter::resolveOutputTags() now rewrites exit-id tags to the exit's place at all three firing sites, before the exit choice and the per-place delivery. takenExits() additionally honours the routing tag among UNCONDITIONED exits: the tagged exit beats declaration order, so the token follows the node's own routing decision instead of landing on the first declared exit for the wrong reason. Conditioned exits (Switch) are unchanged and still win. Regression: FlowLogicTest::testAnItemTaggedWithAnExitIdLandsOnTheExitsPlace and ::testTheTaggedExitBeatsDeclarationOrder (both red before), plus FlowRunServiceAdvanceStreamTest::testTheSyncAdvanceCarriesTheStoredItemsInAndPersistsTheWalkItemsOut pinning the sync persistence seam; tests/e2e/api-direct/flow-user-task.spec.ts:429 is the end-to-end regression on the next rig reset. * fix(task): the inbox reads the subject's real serialised shape, so rows carry their case context ObjectEntity::jsonSerialize() puts the object's own data at top level and its identity under @self (uuid keyed id, mirrored as a top-level id); there is no flat uuid/register/schema/name key. TaskInboxService::subjectContexts() read exactly those flat keys, so the uuid check failed for every real object and every inbox and portal row shipped subject: null — flow-portal-task's seam contract says the row carries its case context, and flow-portal-task.spec.ts:383 pinned the gap deterministically. The unit test's hand-rolled stub returned the flat shape the entity never emits, so it agreed with the exact bug it should have caught. The context now reads @self first (uuid via @self.id / top-level id), with the flat keys kept as fallbacks for stores that serialise flat. The stub is replaced with a real ObjectEntity, and a portal-level test builds the inbox WITH an object store — the portal unit tests used to omit it entirely, which made subject structurally null in every one of them. Regression: TaskInboxServiceTest::testSubjectContextIsBatchedForThePage (now over a real ObjectEntity; red before) and PortalTaskServiceTest::testAPortalRowCarriesItsCaseContext; the e2e at flow-portal-task.spec.ts:383 goes green on the next rig reset. * fix(flow): one unpublished flow no longer silences every flow on its event Two defects with one blast radius. FlowTriggerService::fire() held its try/catch OUTSIDE the per-flow loop, so the queue path's refusal of one enabled-but-unpublished flow ('This flow has no published version') aborted queuing for every healthy flow wired to the same event — the poisoned case got no run from the flow that would have served it. The loop now isolates per flow: a queue refusal logs against THAT flow and the rest queue; a sync flow's inline-execution failure gets its own catch too, because by then the run exists and the worker drains it (the count now says so — reporting zero for a queued run was the instrument lying, and one execution-mode test pin is updated accordingly). And the delivery mechanism: FlowLocator's column fallback treated 'has no index rows' as 'unconverted, columns decide', which is indistinguishable from 'unpublished, subscribed to nothing'. flow-definition-versioning is explicit — only a flow's published version contributes trigger records, a draft's trigger nodes match nothing, and enabled is orthogonal to lifecycle (so enabled:true pre-publish is neither indexed nor refused; the create and enable paths already both index nothing pre-publish, which FlowTriggerIndexPublishedTest pins). The fallback now refuses, out loud, a flow with no published version; legacy flows all hold a backfilled published v1, so the only thing filtered is a flow that could never have run. Unreadable version rows fail open — the queue path still decides. Regression: FlowTriggerServiceTest::testOneUnpublishedFlowDoesNotAbortTheFanOutForItsSiblings and FlowLocatorTriggerCutoverTest::testAnUnpublishedFlowIsFilteredFromTheColumnFallback (both red before this change). * refactor(flow): keep the analyzers' thresholds after the trigger and routing fixes The fixes pushed four methods and one class exactly onto their PHPMD thresholds. Extract rather than suppress: the column-fallback loop moves to FlowLocator::columnFallbackMatches() with the two-rule docblock, the per-object context read moves to TaskInboxService::contextRow(), the router's tag set and tag match get named helpers, and one over-long log line wraps. Behaviour is pinned unchanged by the existing suites. * test(flow): list FlowVersion in the uses rosters the locator seam now exercises * fix(quality): wrap the second over-length log line and cover the new seams' branches CI's phpcs names line 137 of FlowTriggerService (151 chars): the inline failure message gained one indent when it got its own catch, after the local phpcs pass. Wrapped like its sibling. The pgsql cell's coverage guard measures the code this change keeps or adds, and the fixes added statements whose failure branches no test walked. Covered now, each in the suite that owns the seam: the subject-projection refresh's identity fallbacks and no-ops (FlowItemsRefreshTest), the per-place buffer rewrite on resume (FlowRunServiceTest), the locator's fail-open on an unreadable version table (FlowLocatorTriggerCutoverTest), the router's tag resolution across multi-place, unroutable and already-resolved tags plus the tag-over-declaration-order choice (FlowTokenRouterEndpointsTest), and the inbox's flat-serialisation fallback (TaskInboxServiceTest). * test(flow): count the trigger and locator branch coverage the strict cell was discarding The coverage guard still read the fan-out isolation and fallback-filter branches as uncovered: their pins live in files with docblock rosters, and beStrictAboutCoverageMetadata marks a roster'd test risky the moment it executes an unlisted class, discarding that test's WHOLE coverage. So the cutover suite's refusal branch and the execution-mode suite's inline paths counted for nothing (CI's coverage run shows Risky: 314). Two-sided fix. The rosters are completed where they were short (Db\Flow on the cutover suite; Db\FlowRun and FlowItems on the execution-mode suite), and the branches are additionally exercised from roster-free suites, which the cell always counts: the locator's unpublished-flow refusal and version- table fail-open in FlowLocatorTest, the locator-failure and inline-failure catches plus the subjectless payload seed and unresolvable-subject paths of runInline in FlowTriggerServiceTest, the router's malformed/unwired exit skips, and the getObject() subject shape in FlowItemsRefreshTest.
1 parent 82ed322 commit 14bd30f

20 files changed

Lines changed: 1540 additions & 49 deletions

lib/Service/Flow/FlowEngine.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,9 @@ public function run(
490490
'durationMs' => 0,
491491
];
492492

493+
// An exit-id tag becomes its place BEFORE routing, so the
494+
// exit choice and the per-place delivery read the same name.
495+
$items = $this->router()->resolveOutputTags(flow: $flow, transition: $transition, items: $items);
493496
$taken = $this->router()->takenExits(flow: $flow, transition: $transition, items: $items, context: $context);
494497
$placeItems = $this->placement()->advanceItems(
495498
transition: $transition,
@@ -657,6 +660,12 @@ public function run(
657660
$this->runContext?->pop();
658661
}//end try
659662

663+
// A routing step tags items with the exit id its rule named;
664+
// resolve those to the exit's PLACE first, so the choice below and
665+
// the per-place delivery in advanceItems() read the same name —
666+
// unresolved, a routed branch fired with zero items.
667+
$items = $this->router()->resolveOutputTags(flow: $flow, transition: $transition, items: $items);
668+
660669
// Which single exit this firing takes. A token is unique and
661670
// exclusive, so a branching node hands it to exactly one successor.
662671
$taken = $this->router()->takenExits(flow: $flow, transition: $transition, items: $items, context: $context);
@@ -1075,6 +1084,10 @@ private function fireOnStream(
10751084
?string $streamError = null,
10761085
): array {
10771086
$name = $transition->getName();
1087+
1088+
// Same tag resolution as the single-stream walk: an exit-id tag
1089+
// becomes its place before the exit choice and the item delivery.
1090+
$items = $this->router()->resolveOutputTags(flow: $flow, transition: $transition, items: $items);
10781091
$taken = $this->router()->takenExits(flow: $flow, transition: $transition, items: $items, context: $context);
10791092
$placeItems = $this->placement()->advanceItems(transition: $transition, placeItems: $placeItems, items: $items, taken: $taken);
10801093
$workflow->apply(subject: $subject, transitionName: $name);

lib/Service/Flow/FlowItems.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,93 @@ public static function fromSubject(object $subject): array {
209209
return [self::item(json: $json)];
210210
}//end fromSubject()
211211

212+
/**
213+
* Refresh the subject's own fields on every item that IS the subject.
214+
*
215+
* The seam between two requirements that are compatible but easy to
216+
* conflate. Items survive the pause (flow-runs REQ-FR-003): resuming
217+
* continues from the stored list, never a fresh seed, so everything the
218+
* earlier steps produced is kept. But an item that is the run's SUBJECT
219+
* began as a trigger-time snapshot of it, and a step that branches on a
220+
* subject field after a human answered — "is the description filled in
221+
* now?" — must read the subject as it stands, not as it stood when the
222+
* run started. Without this, the shipped re-ask loop can never succeed:
223+
* the answer lands on the object, the re-check reads the frozen snapshot,
224+
* and the run strands.
225+
*
226+
* So: only items carrying the subject's identity are touched, and on
227+
* those the live subject's serialised fields are merged OVER the stale
228+
* snapshot. A key the live subject does not serialise — a task outcome
229+
* bag, a step's computed field — survives untouched, which is what keeps
230+
* REQ-FR-003 true. An item that is some OTHER object (a fan-out read)
231+
* carries its own identity and is never smeared with the subject's data.
232+
*
233+
* @param array<int, array<string, mixed>> $items The stored items.
234+
* @param object $subject The live subject.
235+
* @param string $subjectUuid The run's subject uuid, matching items by identity.
236+
*
237+
* @return array<int, array<string, mixed>> The items, subject fields refreshed.
238+
*
239+
* @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md#requirement-resuming-carries-the-runs-own-items-req-fr-003
240+
*/
241+
public static function refreshSubjectProjection(array $items, object $subject, string $subjectUuid): array {
242+
$subjectUuid = trim($subjectUuid);
243+
if ($subjectUuid === '' || $items === []) {
244+
return $items;
245+
}
246+
247+
$live = (array)(self::fromSubject(subject: $subject)[0][self::JSON] ?? []);
248+
if ($live === []) {
249+
return $items;
250+
}
251+
252+
foreach ($items as $index => $item) {
253+
if (is_array($item) === false) {
254+
continue;
255+
}
256+
257+
$json = (array)($item[self::JSON] ?? []);
258+
if (self::identityOf(json: $json) !== $subjectUuid) {
259+
continue;
260+
}
261+
262+
// Live wins on the subject's own keys; step-produced keys the
263+
// subject does not serialise are kept as the run carried them.
264+
$items[$index][self::JSON] = array_merge($json, $live);
265+
}
266+
267+
return $items;
268+
}//end refreshSubjectProjection()
269+
270+
/**
271+
* The object identity an item's record carries, if any.
272+
*
273+
* A subject-seeded item serialises its uuid as `@self.id` mirrored at the
274+
* top-level `id`; older shapes carried a flat `uuid`. An item with none of
275+
* these has no object identity and can never be "the subject".
276+
*
277+
* @param array $json The item's record.
278+
*
279+
* @return string The identity, or '' when the item carries none.
280+
*
281+
* @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md#requirement-resuming-carries-the-runs-own-items-req-fr-003
282+
*/
283+
private static function identityOf(array $json): string {
284+
$self = ($json['@self'] ?? null);
285+
if (is_array($self) === true && trim((string)($self['id'] ?? '')) !== '') {
286+
return trim((string)$self['id']);
287+
}
288+
289+
foreach (['id', 'uuid'] as $key) {
290+
$value = ($json[$key] ?? null);
291+
if (is_string($value) === true && trim($value) !== '') {
292+
return trim($value);
293+
}
294+
}
295+
296+
return '';
297+
}//end identityOf()
298+
212299
/**
213300
* Read an already-set `pairedItem` index, falling back when absent.
214301
*

lib/Service/Flow/FlowLocator.php

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
use OCA\OpenRegister\Db\Flow;
3737
use OCA\OpenRegister\Db\FlowMapper;
3838
use OCA\OpenRegister\Db\FlowTriggerMapper;
39+
use OCA\OpenRegister\Db\FlowVersionMapper;
3940
use OCA\OpenRegister\Db\ObjectEntity;
4041
use OCA\OpenRegister\Service\ObjectService;
4142
use Psr\Log\LoggerInterface;
@@ -66,12 +67,14 @@ class FlowLocator {
6667
* @param FlowTriggerMapper $triggerMapper The node-derived trigger index.
6768
* @param ObjectService $objectService Loads the object a run is about.
6869
* @param LoggerInterface $logger Records refusals and failures.
70+
* @param FlowVersionMapper $versions Answers whether a fallback-matched flow has a published version.
6971
*/
7072
public function __construct(
7173
private readonly FlowMapper $mapper,
7274
private readonly FlowTriggerMapper $triggerMapper,
7375
private readonly ObjectService $objectService,
7476
private readonly LoggerInterface $logger,
77+
private readonly FlowVersionMapper $versions,
7578
) {
7679

7780
}//end __construct()
@@ -221,19 +224,10 @@ public function flowsForTrigger(string $event, string $register, string $schema)
221224
return [];
222225
}
223226

224-
// The column match is kept ONLY for flows the index does not represent.
225-
// A converted flow's columns are stale by construction — they hold one
226-
// trigger where its nodes may hold several — so consulting them for a
227-
// converted flow would resurrect a subscription its author removed.
228-
$matched = [];
229-
foreach ($candidates as $flow) {
230-
$uuid = (string)$flow->getUuid();
231-
if (isset($converted[$uuid]) === true) {
232-
continue;
233-
}
234-
235-
$matched[$uuid] = $flow;
236-
}
227+
// The column match is kept ONLY for unconverted flows, and among those
228+
// only for flows a published version can back — see the docblock of
229+
// {@see self::columnFallbackMatches()} for both halves of that rule.
230+
$matched = $this->columnFallbackMatches(candidates: $candidates, converted: $converted, event: $event);
237231

238232
// The index answers in UUIDs; the dispatch check needs the row. These
239233
// are the flows whose NODES want this event.
@@ -259,6 +253,85 @@ public function flowsForTrigger(string $event, string $register, string $schema)
259253
return $this->dispatchableUuids(matched: $matched, event: $event);
260254
}//end flowsForTrigger()
261255

256+
/**
257+
* The column-fallback matches: unconverted flows a published version can back.
258+
*
259+
* Two rules, one per failure mode this fallback has produced:
260+
*
261+
* - ONLY flows the index does not represent. A converted flow's columns
262+
* are stale by construction — they hold one trigger where its nodes may
263+
* hold several — so consulting them would resurrect a subscription its
264+
* author removed.
265+
* - 🔴 ONLY flows with a published version (flow-definition-versioning:
266+
* a draft's trigger nodes SHALL NOT match anything). The index enforces
267+
* that by derivation from the published graph, but the fallback did not:
268+
* an enabled flow that was never published has zero index rows, so it is
269+
* "unrepresented", its columns match, and `queue()` then refuses it with
270+
* "no published version" — a refusal that used to abort the whole
271+
* fan-out for every healthy flow on the same event. Refused OUT LOUD
272+
* here instead. Legacy flows all hold a backfilled published version 1,
273+
* so the only thing filtered is a flow that could never have run.
274+
*
275+
* @param array<int, Flow> $candidates The flows whose columns match the event.
276+
* @param array<string, int> $converted Uuids the index represents, flipped for lookup.
277+
* @param string $event The trigger being resolved, for the log line.
278+
*
279+
* @return array<string, Flow> The matches, by uuid.
280+
*
281+
* @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md#requirement-trigger-matching-answers-which-flow-the-queue-path-answers-which-version
282+
*/
283+
private function columnFallbackMatches(array $candidates, array $converted, string $event): array {
284+
$matched = [];
285+
foreach ($candidates as $flow) {
286+
$uuid = (string)$flow->getUuid();
287+
if (isset($converted[$uuid]) === true) {
288+
continue;
289+
}
290+
291+
if ($this->hasPublishedVersion(uuid: $uuid) === false) {
292+
$this->logger->warning(
293+
message: '[FlowLocator] Flow "' . $uuid . '" matched trigger "' . $event
294+
. '" through its columns but has no published version, so it cannot back a run.'
295+
. ' Publish a version to make it fire.',
296+
context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $uuid]
297+
);
298+
continue;
299+
}
300+
301+
$matched[$uuid] = $flow;
302+
}//end foreach
303+
304+
return $matched;
305+
}//end columnFallbackMatches()
306+
307+
/**
308+
* Whether a flow has a published version to back a run.
309+
*
310+
* FAIL OPEN on an unreadable version table: silencing every fallback flow
311+
* because one lookup failed would stop the engine without a word, and
312+
* `FlowRunVersionPin` re-checks the same fact at queue time anyway — this
313+
* filter only decides whether a flow is worth OFFERING to the queue.
314+
*
315+
* @param string $uuid The flow's uuid.
316+
*
317+
* @return bool True when a published version exists, or when the answer is unknowable.
318+
*
319+
* @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md#requirement-trigger-matching-answers-which-flow-the-queue-path-answers-which-version
320+
*/
321+
private function hasPublishedVersion(string $uuid): bool {
322+
try {
323+
return $this->versions->findPublished(flowUuid: $uuid) !== null;
324+
} catch (Throwable $e) {
325+
$this->logger->warning(
326+
message: '[FlowLocator] Could not read the published version of flow "' . $uuid . '": '
327+
. $e->getMessage() . '. Keeping it in the match; the queue path decides.',
328+
context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $uuid]
329+
);
330+
331+
return true;
332+
}
333+
}//end hasPublishedVersion()
334+
262335
/**
263336
* The uuids of the matched flows that can actually be dispatched.
264337
*

lib/Service/Flow/FlowRunService.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ public function advanceStream(FlowRun $run, array $flow, object $subject, string
269269

270270
$flow = $pinned;
271271

272+
// Resume-after-human-answer must see the subject as it stands: the
273+
// answer the task asked for landed on the OBJECT, and the stored items
274+
// still hold the trigger-time snapshot of it.
275+
$this->refreshSubjectItems(run: $run, subject: $subject);
276+
272277
$walk = $this->streamWalkFor(run: $run, flow: $flow, onlyStream: $streamId, budget: $firings);
273278
if ($walk === null) {
274279
// Without the stream layer there is no branch to scope to; the
@@ -823,6 +828,12 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed
823828
$flow = $pinned;
824829

825830
$resuming = ($run->getStatus() === FlowRun::STATUS_SUSPENDED);
831+
if ($resuming === true) {
832+
// Stored items win on resume (below), but the subject's own fields
833+
// on them are a trigger-time snapshot. {@see self::refreshSubjectItems()}
834+
$this->refreshSubjectItems(run: $run, subject: $subject);
835+
}
836+
826837
$run->setStatus(FlowRun::STATUS_RUNNING);
827838
$run->setUpdated(new DateTime());
828839
$this->mapper->update($run);
@@ -895,6 +906,60 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed
895906
return $this->persistResult(run: $run, result: $result);
896907
}//end execute()
897908

909+
/**
910+
* Refresh the subject's fields on a resumed run's stored items, in place.
911+
*
912+
* Both stores are refreshed because both are read on resume: the flat
913+
* `items` list feeds a legacy walk, and the per-place buffers are what
914+
* {@see FlowItemPlacement::seedPlaceItems()} prefers — the check node that
915+
* re-enters after a human answered reads ITS input place's buffer, and a
916+
* refresh that missed it would leave the stale snapshot exactly where the
917+
* branch decision is made.
918+
*
919+
* In place and before the walk's first persist, so the commit path — which
920+
* re-reads the run row under its lock — sees the refreshed buffers too.
921+
*
922+
* A subjectless run, or one whose stored items never carried the subject,
923+
* is untouched: {@see FlowItems::refreshSubjectProjection()} matches by
924+
* identity and only ever touches the item that IS the subject.
925+
*
926+
* @param FlowRun $run The resumed run, mutated in place.
927+
* @param object $subject The live subject, as the advancer just resolved it.
928+
*
929+
* @return void
930+
*
931+
* @SuppressWarnings(PHPMD.StaticAccess) FlowItems::refreshSubjectProjection
932+
* is a pure function on value shapes; a factory to call it would add a
933+
* dependency to say the same thing.
934+
*
935+
* @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md#requirement-resuming-carries-the-runs-own-items-req-fr-003
936+
*/
937+
private function refreshSubjectItems(FlowRun $run, object $subject): void {
938+
$subjectUuid = trim((string)$run->getSubjectUuid());
939+
if ($subjectUuid === '') {
940+
return;
941+
}
942+
943+
$run->setItems(FlowItems::refreshSubjectProjection(
944+
items: ($run->getItems() ?? []),
945+
subject: $subject,
946+
subjectUuid: $subjectUuid
947+
));
948+
949+
$stored = $run->getPlaceItems();
950+
if (is_array($stored) === true && $stored !== []) {
951+
foreach ($stored as $place => $bucket) {
952+
$stored[$place] = FlowItems::refreshSubjectProjection(
953+
items: (array)$bucket,
954+
subject: $subject,
955+
subjectUuid: $subjectUuid
956+
);
957+
}
958+
959+
$run->setPlaceItems($stored);
960+
}
961+
}//end refreshSubjectItems()
962+
898963
/**
899964
* Fail a run whose pinned version cannot be resolved.
900965
*

0 commit comments

Comments
 (0)