Skip to content

Run and Inbox modes for Dex Web v2 - #511

Open
longquanzheng wants to merge 21 commits into
mainfrom
zzheng/run-and-work-queue-modes
Open

longquanzheng wants to merge 21 commits into
mainfrom
zzheng/run-and-work-queue-modes

Conversation

@longquanzheng

@longquanzheng longquanzheng commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Two modes for Dex Web v2, after a process has been developed: a semantic Run view for an admin, and an Inbox for a participant who has to act. Chat is not part of this — it was a mock in the prototype and Dex has not enabled it.

What the branch does

Run view — the primary admin view. A canvas of the flow with a right-hand drawer per run. Non-technical by default: each Step says what it does in the flow's own terms, sourced from dex:explanation rather than from prose kept beside the code. Selecting a run with an open Action focuses the Step it is stopped at and opens the drawer; closing resets. Clicking a Step with no run selected still explains the Step, because a definition is a readable artifact on its own.

Deep Dive — the v1 detailed view, nested rather than replaced. Its layout and information density are kept intact; only colour and typography are unified, in a .v2-debug scoped layer. Reached from the run header, so v1's technical depth is one click away instead of gone.

Inbox — the participant list. Shares one RunList with the Run view so the two cannot drift into two visual languages. The role contract is not in this branch: it still lists runs rather than "what needs me".

Two human gates in the refund example. RequestHumanApprovalStep and a new ConfirmCustomerMessageStep, so multiple actionable Steps per run is exercised by a real flow rather than asserted. customer-email and refund-amount are now Indexed Attributes; amount is in dollars.

Canvas layout is governed by measured principles. layoutQuality.ts declares what a drawing must achieve, each with a metric and a limit, and the corpus test runs every shipped definition in all four view modes against them. This replaced tuning constants against one screenshot at a time.

Review feedback addressed

1. Restore and simplify run search. Status and time are the default view — the two questions every Flow answers whether or not it declared anything indexable. An exact run id and one attribute comparison sit behind Advanced search. The operator list is derived from the index type, so the UI cannot compose a query the server rejects: contains only for fulltext, ordering only for int, double and datetime. The value control follows the Attribute's value type. No keyword search over payloads, inputs, outputs or history — only declared Indexed Attributes, named in the picker from the contract. No multi-row query builder.

Checked against what FilterBuilder had before it was removed: operators, the bool select and the datetime input are restored, and run-id lookup is restored — it had been lost, and searching for a known run is among the most common reasons to open the panel. in stays out; it needs a comma convention in placeholder text and a second value control as soon as anybody wants two ranges.

2. Multiple human-actionable Steps per run. Derived from the graph, not declared: a Step is actionable when something outside the flow publishes to the Channel it waits on. A flow that grows a third gate grows a third entry with no UI change. The drawer shows a progress strip, defaults to the first, marks the current one, and advances to the next pending gate on completion instead of closing — closing was right when one Action ended the run, but with another gate pending the reader should not have to find it again. Nothing requires navigating back into run history.

3. Existing Dex interaction model preserved. The Run view, Process Canvas and Inbox are not redesigned. Changes are additive or fixes to what was already there.

Fixes found along the way

  • Edges were invisible. The stroke-width rule lost to @xyflow/react's own stylesheet at equal specificity, so every edge sat at the 1px library default — the earlier 1.5px had always been a no-op. Auto-focusing a Step also reused the reader's selection path and dimmed 43 of 47 edges.
  • Region merging under-converged. The greedy merge bounded its passes by the live rects.length, which shrinks on every merge while the counter rises. A group spanning seven rows stopped after four merges and shipped three regions where one was available. This affected every flow, not just the one on screen.
  • Lanes are assigned per group, not per Step. Reachability classifies a Step correctly and still drew Failure as five regions in three columns, because only three of its members are reached by failing. A group that is mostly error handling is error handling, so all of it shares the recovery lane.
  • Changing Indexed Attributes on a store that already holds runs of that Flow type stops the Worker before it binds its port — the symptom is silence, not an error. Recorded in examples/go/README.md.
  • Dead surface removed: describeFilters, QUEUE_COPY.scope, unfilteredHint and onThisPage were reachable only from one test, which is why nothing flagged them.

Verification

  • web: 208 tests, npm run check clean.
  • docs: npm run check clean — both locales build, 156 indexable routes, 7 redirects, SEO audit passed.
  • make copyright-check clean.
  • All four operators, run-id lookup and both gates exercised against a live server: amount >= 99999 → 0 rows, >= 0 → 5, email contains northwind → 2, contains nosuchdomain → 0, runId = adv-1 → 1.
  • Layout guardrails were each broken on purpose to confirm they fail. Three earlier attempts at one were vacuous, so a green guardrail is not taken as evidence.

Known gaps, deliberately not in this branch

  • The role contract. dex:field role: and dex:role-attribute, a role picker, and "needs my action" ordering. Without it Inbox is a run list, not a participant view.
  • Region principles are thinly covered. Only 2 of 33 shipped definitions declare groups — dex:group is FDG 2.0 and the other 31 are 1.0 — so tight-regions and group-cohesion are exercised by 8 of 132 scenes. Pinned by name so stale regenerated JSON cannot quietly empty them. Porting a second FDG 2.0 flow is the way to widen it.
  • Multi-role validation needs the 18-step, 5-party onboarding flow ported to Go.
  • dex-web.mdx and its zh-Hans mirror are not updated for the two modes.

One /v2 screen served two jobs at once: driving a run on the definition
canvas, and clearing work that needs a person. Separate them.

/v2/run keeps today's behaviour — listing, Display, Actions and the
canvas — and gains a link to the v1 run page, which owns Timeline, the
event browser, Stop and Time Travel.

/v2/queue draws no canvas. Clearing a queue does not need the shape of
the process, so "See the process" opens the same run in Run mode instead.

The listing, the filter builder and the Display/Actions panel are shared
rather than duplicated, so the two modes cannot drift apart.

No contract, API or analyzer change.
The queue blanked its rows whenever a refresh failed, so "nothing is open" and
"we cannot see the process" rendered identically while calling for opposite
actions. Fold reads through a held value instead: a failed refresh keeps the
rows that were true a moment ago and says they are stale.

A run whose owning worker has exited is a third answer again — permanently
unreadable while the server stays healthy. Measured rather than assumed: that
surfaces as FailedPrecondition, the same code the server uses for "Flow is not
active", and a closed run produces a byte-identical error. Neither the code nor
the message separates them, so stranded is (running run, dial failure) and is
only knowable once somebody opens the run. The row is then marked, not dropped,
because it is still unresolved.

readResponseJSON parsed grpcCode and discarded it; it now reaches the caller on
a DexAPIError without altering any existing message.

Queue mode narrows to open runs through a real server-side filter rather than
dropping rows after the server paginated, and states its own scope: which
status it filtered on, that counts describe one page, and that Action
availability is decided per run when opened.

Deliberately not here: no attention kinds, no tally, and no claim that a person
is needed. A dex:when condition says when an Action may be offered, not who
must act, and the search index it would be evaluated against is eventually
consistent. That needs a declared role, which the contract does not yet carry.
Four defects, all found by auditing the branch rather than by a test.

A dead worker was only explained in Queue mode. Run rendered SelectedRunPanel
without the run's status, so isStrandedRunFailure could never fire there and an
operator saw a raw gRPC dial error instead of the sentence that says the process
itself is fine. Both modes now pass the status and remember what they learn, so
the row is marked in either one.

The Display panel ignored the liveness fold the list beside it was already
using, so half the view could tell a stale answer from a fresh one and half
could not. It now holds its value the same way, and a write that fails is kept
separate from a read that went stale — a rejected Action does not make the
fields old.

The queue's scope sentence was hardcoded while the filter row it described was
user-deletable, so deleting the filter left the view claiming a scope it no
longer had. It is now derived from the filters that are actually applied, and
says plainly when nothing is narrowing the list.

A failed search printed its reason twice.

Also: nineteen classes ported from the prototype matched no markup. The ones
whose markup this change restores are now used; the rest are deleted and will
return with the commits that introduce theirs. sc-actions was the inverse — a
class in use that nothing styled.
/v2/run/:flowType/:flowId/debug/:runId renders the v1 run-details components
unchanged inside the v2 shell. Layout and information density are preserved
deliberately: this is the view an engineer drills into, and its four tabs are
already the comprehensive record.

Keyed per run, because Time Travel and continue-as-new navigate the run chain
while the Run view only ever shows the current run. With no runId in the URL it
resolves the current one.

/v1 keeps serving the same components, so globals.css cannot be rewritten in
place. debug.css is therefore a colour-and-type layer scoped under .v2-debug,
the same shape as the .v2-shell .ppan block already in canvas.css. globals.css
still owns the layout.

Mapped by meaning, not by literal. v1 spells its palette as 74 distinct hex
values and uses green for three different things; v2 reserves green for done, so
interactive affordances move to blue. v2 has no interactive token at all — every
--p-* is a status — so the accent is aliased once and named.

RunDetailsPage now takes its breadcrumb and its run-chain path from whichever
shell hosts it, passed explicitly at both call sites rather than defaulted.

Verified by measurement rather than by eye: a probe walks every element in all
four tabs in dark mode looking for a light background, and reports zero. The
first version of that probe was wrong — it split rgba alpha on \d+ and read
0.97 as 0 — which is how a white panel header survived two passes.

Known remaining: the Deep Dive's own deep links (SubFlow drill-downs, previous
runs) still point at /v1/flows. They are reachable and correct there; routing
them back into v2 needs a context rather than nine more props.
Three panes with the weights the job wants: a narrow run switcher, the canvas
wide, and a drawer for details and actions. Before this, one left aside stacked
a flow-type picker, a filter builder, a result list, the Display fields and the
Actions, and the canvas — the thing the view is about — got what was left.

Choosing a run now lands on where it stopped. The canvas reveals and selects the
Step the run is waiting on, and the drawer's top band names it with the
templated why-line the canvas already had. Clicking another Step moves the band
and nothing else: the run's facts and its Actions do not move, because Actions
are why an Admin opened the run. They are sticky to the drawer's bottom edge.

The Display pane was capped at 42% of the aside it shared, which is why its
Reject button fell off the bottom of the window. Its own column has no cap.

Promoted: the run's identity — run ID, started, elapsed — which v2 never
rendered even though the canvas already fetched it; and Stop, which v2 could not
do at all. Scoping a search left for the Queue.

One clock. The canvas owned a 5s run poll while the Display pane was a one-shot
read, so the diagram moved and the fields silently did not. The canvas now
reports each poll and the drawer refreshes on the same beat.

Caught while verifying: suppressing nothing left FOUR columns, because the
canvas still rendered its own Step panel beside the new drawer — the canvas was
squeezed worse than before. The canvas now yields the detail surface to its
host.

Deferred, deliberately: the Step's WaitFor conditions and Execute branches are
definition structure and stay in the Deep Dive. The band carries the Step's
dex:explanation, which is the semantic half.
Four things the Run view got wrong.

The runs were numbered 1 to 4. Nothing chose those numbers: the list was an <ol>
and only the queue's own class reset list-style, so they were browser ordinal
markers standing in for an order they did not describe. The real order was
already there — the search handler sorts by start time descending — so the fix
is to state it rather than decorate it, and to lift open runs above closed ones.
Which run most needs somebody still cannot be ranked: that wants a declared role.

Each run was drawn as a bordered, filled card, so four runs read as four objects
competing with the canvas. Now hairlines between rows, a heavier rule between
the open and closed groups, and selection as a left accent plus a faint fill.
A run is an entry in a list.

Actions were below the reported fields and pinned to the drawer's bottom edge.
They now sit directly under the band that says what the run is waiting at, which
is where somebody who already knows the case looks. The pinning goes with them,
because nothing is below the fold any more. The Queue deliberately keeps the
other order: a participant needs the evidence before the decision, which is the
argument the prototype's panel was built around. One prop, stated at both call
sites.

"Deep dive" said nothing about what was behind it. "Inspect" borrows the
devtools convention, so it signals advanced without a label claiming to, and a
bordered mono chip reads as an engineering surface in a design system that
already spells technical values in mono.

Also: the drawer named the run twice, once in its header and once in the panel
below it.
Type. The v2 ramp ran 9 to 11.5px, which is below comfortable reading size for
prose values like a recommendation rationale. Everything moves up one tier.

Fit. Fit was capped at 1:1 on the reasoning that a three-Step flow blown up to
fill a wall reads as a mistake, but the cap also left a wide canvas mostly empty
whenever the graph already fitted. Raised, and the padding trimmed — the top
reserved 22% for a control bar that needs about 6%. Measured: the graph now uses
89% of the width and 80% of the height, up from 82% and 73%.

Arrowheads. They are markerUnits="userSpaceOnUse", so unlike the stroke they do
not take the zoom compensation — a 7-unit head renders as 3.5 screen pixels at
half zoom, which is why direction was unreadable without zooming in. Enlarged.
A dimmed edge was at 0.24 opacity, so selecting a Step made the rest of the
skeleton vanish rather than recede; it now stays a visible line.

Choosing a run with an open Action zooms in on the Step it stopped at, rather
than panning to it at whatever zoom was current, and closing the panel gives the
whole graph back. The zoom is a prop rather than an imperative call, because the
drawer opening resizes the pane and the resulting refit was racing the focus and
winning.

The drawer has a close button, and taking an Action closes it: the thing it was
opened for has been resolved, and the run's status updates in both panels.

Timezone follows absolute timestamps into the Deep Dive, which is the only view
that turns on them.

"Work queue" is now "Inbox" — the most familiar name for a place things arrive
for you, and role-specific in a way a word like "queue" is not.

A stripe under the canvas was the run-selection prompt: a third grid child with
nowhere to sit, so it wrapped onto its own row. It is hover help on the Runs
heading now, the canvas reaches the bottom, and the order sentence goes with it.
Flow type is a select rather than a stack of buttons.
The Run drawer showed a run and its Actions but never said what the Step it was
waiting at is FOR. That is a Flow-level fact, not a run one, so it appends below
the reported fields rather than displacing them.

Purpose first, then placement: the declared explanation, the phase the Step sits
in, who moves it along, what reaches it, what it waits for, where it goes next,
and where it goes if it fails. Every line is read from the graph. A Flow that did
not declare a purpose gets a sentence saying so rather than an invented one.

Recovery is kept out of "then goes to". A failure edge is a different fact from
the ordinary path, and merging them would claim the Step routes somewhere it only
reaches when something breaks.

The wait line reuses the wait phase's own prose instead of rebuilding it from
condition labels — that string already paraphrases the framework vocabulary while
keeping identifiers verbatim.

THE COMMITTED FLOW DEFINITIONS WERE STALE, and that is why this looked
impossible. Both refund sources declare exactly one dex:explanation per Step, 30
in total, as the repository requires — but the JSON under
docs/src/data/flow-definitions carried no metadata at all, so every Step read
"No dex:explanation on this Step" in the Deep Dive too. Regenerated from the same
sources: 7 of 7 and 23 of 23. Note that Dex snapshots the rendering directory at
startup, so a regeneration needs a restart to be visible.

Also: the explanation was briefly in the band as well, and a grown case panel
left a gap above the new block.
The Step block was flush against the drawer's left edge and ran to its bottom.
Its neighbour pads itself; it did not.

And a Step's meaning does not depend on a run, so clicking a Step with nothing
selected now opens the drawer on the Step alone: no run header, no waiting band,
no fields and no Actions, because none of those exist yet. Closing it clears the
canvas selection rather than leaving a Step highlighted with nothing explaining
it.

That also makes the drawer's two jobs explicit. With a run it answers "what is
this run waiting on and what can I do"; without one it answers "what is this Step
for", which is a question about the Flow.
Inbox was still the old shell while Run had been rebuilt around it, so the two
read as different products: a three-line page title Run does not have, a 26rem
pane carrying a filter builder and a pager, rows of unlabelled values, a wrapping
"Ask again", full timestamps against Run's time-only, and a case panel with no
chrome spread across 1250px so a label and its value sat a screen apart.

Unified by sharing the component rather than copying its styles. RunList is the
one list both views render, with the heading, the note beside it and an optional
scope control as props — the rows are identical by construction and cannot drift.
FlowListing and RunSwitcher are gone; they were two implementations of it.

The filter builder moves into its own component behind a disclosure. Collapsed,
so the list reads as a list; never hidden, because the view states what it
narrowed to and a reader who disagrees has to reach the thing that narrowed it.
The sentence is the summary line, so it is visible either way.

The case panel is capped and centred: a reading column with a void beside it
reads as broken, and a label-value pair should not span a screen. It gains the
close button Run's drawer already had.

The page title goes. The nav says Inbox, and the note explaining the absent
diagram was answering a question that only existed before Run was its own view.

What still differs is deliberate: no canvas, and evidence before the decision.
@codecov

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@longquanzheng
longquanzheng marked this pull request as ready for review September 20, 2026 18:05
A refund run now stops for a person twice: once to approve the refund, then
again to confirm or rewrite the message that goes to the customer. Only one gate
is ever open, and the second one holds the email back until somebody has read it
— before this, the message was composed and sent in the same breath.

Three Steps rather than one, copying the shape the approval gate already uses:
DraftCustomerMessageStep composes the message and mints the gate,
ConfirmCustomerMessageStep only waits, and SendCustomerMessageStep sends whatever
was confirmed. The predecessor mints the key so nothing writes inside a wait_for
phase, which is the mistake that registers an empty wait condition and parks a
Step forever.

The gates share one counter and one gate-request-key, so the keys read
:gate:1 and :gate:2, and each waits on its own Channel so neither can consume
the other's answer. Two Actions, not three: refusing to tell the customer
anything is not a decision worth offering.

Searching. Dex can only search Indexed Attributes, and this Flow declared one —
case-status, keyword, exact match only. It now declares the two a person
actually remembers a case by: customer-email as fulltext, so either the whole
address or just the domain finds it, and refund-amount as a double in dollars,
so a range reads like the figure people say out loud. RefundCase gained the
email address, which it never carried.

RECORDED IN examples/go/README.md: changing a Flow type's Indexed Attributes
while a store still holds runs of that type can stop the Worker before it binds,
and it does so silently — the process lives, logs nothing after Gin's banner, and
never opens its port, so requests fail to connect instead of returning an error.
A fresh store proves it, which is how this was found. The README already said
the sync happens before the listener opens; it did not say what happens when the
sync cannot finish.

The analyzer caught a real error on the way: the new Steps were inserted between
SendCustomerMessageStep's directives and its type, leaving one Step with two
explanations and another with none. dexcli refused to emit until each Step had
exactly one.
Four controls, not a query builder: a keyword box, a status, a time window, and
one exact attribute value. Every one compiles to a filter the search endpoint
accepts, so the UI cannot ask for a query the server will reject.

The keyword box searches the Flow's one full-text Attribute and renders ONLY when
the Flow declares one — the deterministic refund Flow has none, so it simply has
no keyword box rather than a box that quietly matches nothing. Its placeholder
names the Attribute it searches, and a line under the controls names all of them,
both read from the contract rather than written as prose that drifts from it. It
also says what is NOT searched: not run inputs, outputs or history.

Time is relative — last hour, 24 hours, 7 days — because nobody remembers a
timestamp, and it compiles to an absolute lower bound the index understands.

Filtering stays server-side, so it still holds at ten thousand runs. The earlier
client-side idea would have searched only the fifty rows already on screen.

The filter rows now have one owner. useFlowSearch takes them rather than holding
them, and they are recompiled on submit rather than on change, so a half-typed
keyword is never a query and the rows the list was searched with stay stable while
the next one is typed.

Verified against three live runs: the domain fragment northwind.com narrows three
to two, an exact amount in dollars finds one, Clear restores all three.
A refund now stops for a person twice, and the drawer says so: a strip of the
Flow's human gates, each done, current or upcoming, with how many are left.
Answering one advances to the next in place — no going back through Run history
to find it.

Derived rather than declared. A Step is human-actionable when something outside
the Flow publishes to the Channel it waits on, which answeredBy already reads off
the graph, so this needed no contract change and a third gate would need no UI
change. Order is breadth-first from the start, because an upcoming gate has no
execution to sort by and still has to appear in the right place.

A gate a branch skipped reads done, not pending. Small refunds never reach the
manager but still need their message confirmed, and leaving that gate "upcoming"
forever would make the count a lie.

Only one gate is ever open, so this reads as progress through a sequence and not
a list of things that could be done now. The strip is absent for a Flow with one
gate, where a progress indicator would be noise.

THIS REVERSES PART OF AN EARLIER CHANGE. Taking an Action was made to close the
drawer, which was right when one Action ended the run; with a second gate pending
it threw away the thing the reader needed next. It now closes only when no
Action remains.

Verified on a live run: Approve moves the strip from
current/upcoming to done/current, swaps the Actions to "Send as written" and
"Rewrite and send", re-points the band, and leaves the drawer open.
Two Canvas regressions from the guided-action work.

Edges were invisible. Auto-focusing the blocked Step reused the reader's
selection path, so the dim pass hid 43 of 47 edges; and the stroke-width
rule lost to @xyflow's own stylesheet at equal specificity, leaving every
edge at the 1px default. Dimming is now opt-in per selection source, and
the width rule is qualified by .pedge so it applies at all.

Nodes were also too far apart. The nodesep/ranksep graph options are inert
here because this layout only takes within-rank order from dagre, so the
real lever is rankBase, now 44. Two overlap guards over the shipped refund
graph pin the floor: both fail at 36.
…shot tuning

Spacing was being tuned one screenshot at a time, so a constant that helped
one graph could quietly hurt twelve others. `layoutQuality` states what a
drawing has to achieve, gives each principle a metric, and the corpus test
runs all 33 shipped Flow definitions in all four view modes against them.

That measurement located the reported sprawl without being told where to
look: the refund flow was the corpus worst on region waste. Its Failure
region reached from the spine across an empty lane to the recovery gutter,
because a row rectangle spanned members separated by nothing at all, and the
merge pass only rejected unions that swallowed a foreign card.

Regions are now contiguous. A gap that could hold another card is a hole, and
stacked rows merge where side-by-side rectangles do not. Worst-case region
waste falls 0.876 to 0.731 against a 0.493 floor set by padding and the label
strip, and Failure becomes five tight regions instead of one sprawling band.

Placement is unchanged: BillingFailedStep has three ordinary transitions into
it, so the error gutter would have been a false claim about how it is reached.
Group cohesion joins the measured layout principles: a declared group should
be one place, and a same-label region repeated at one rank with a lane between
it is the shape a reader cannot explain. Guarded at zero across the corpus, and
the guardrail bites — disabling the rule fails it on the refund flow.

Cohesion is local and narrow. A STRUCTURAL TWIN moves: same group, same
downstream target, adjacent rank. BillingFailedStep and SubscriptionFailedStep
both catch a failure and both continue to DraftCustomerMessageStep, so only the
reachability test had them in different lanes — one is also a guarded branch
target. They now share the gutter with EmailFailedStep as one region.

NotARefundStep and NonConvergenceStep are in that same group and stay on the
spine, because they continue elsewhere many ranks earlier. Forcing them across
would buy a tighter region with a much longer edge, which is the trade the
short-edges limit exists to hold. The refund flow also comes out 380px
narrower.

Region waste moves 0.731 to 0.751 and mean edge span 5.30 to 5.69, both
deliberate: cohesion is not free, and the limits are where it stops.
Two findings from the same drawing, one a real bug.

REGION MERGING UNDER-CONVERGED. The greedy merge bounded its passes by the live
`rects.length`, which shrinks on every merge while the counter rises, so it could
only ever perform about half the merges available. The refund flow's Resolution
group spans seven rows and stopped after four, shipping three regions where one
was available. Bounded by the starting count, it is now one.

LANES ARE ASSIGNED PER GROUP, not per Step. Reachability decides a Step's lane
correctly and still drew Failure as five regions in three columns, because only
three of its members are reached by failing — the other two are guarded branch
targets that happen to be terminal. A group that is mostly error handling IS
error handling, so all of it shares the recovery lane and Failure becomes one
vertical strip. Majority, not presence, so one compensating Step cannot drag its
whole phase aside; measured over the corpus, Failure is the only group that
reaches the threshold. This replaces the narrower twin rule, which it subsumes.

The tight-regions metric was wrong twice and is now measured against the cards.
Area punished the shape a strip is meant to have. Uncovered width then charged
every region for its own padding and label strip. It now reads the largest
sideways hole in card widths: 0.65 for an ordinary gap between fan members, 1.09
with the merge guard removed, so the limit sits at 0.85 where the data put it.

Both guardrails were broken on purpose to confirm they fail, and README.md
records the loop, the metric traps, and the bugs this machinery has caught.
Regions come from `dex:group`, an FDG 2.0 directive, so the 31 schema-1.0
definitions in the corpus have none and pass `tight-regions` and
`group-cohesion` vacuously. Real coverage for those two is 8 of 132 scenes, not
the 33 flows the report implied, and the report now says so.

The group-bearing definitions are pinned by name. Regenerated JSON that quietly
lost its groups would otherwise leave every region principle green with nothing
to measure, which is the failure mode that already hid stale metadata once.

No analyzer change is needed. It errors unless every Step declares exactly one
dex:group and rejects one group id carrying two labels, so the membership the
layout now reads for lane assignment is total and unambiguous.
…closure

Status and time are the two questions every Flow can answer whether or not it
declared anything indexable, so they are the default view. An exact run id and
one attribute comparison sit under "Advanced search", where choosing an operator
does not tax a reader who only wanted today's failures.

The operator picker is built from the index type rather than offered whole, so
the UI cannot compose a query the server will reject: `contains` appears only
for fulltext, ordering only for int, double and datetime, and an operator that
becomes illegal when the Attribute changes falls back to that type's default.
The value control follows the Attribute's value type, which restores the bool
select and datetime input that the four-control version had dropped.

Restores run-id lookup, which the four-control version lost. Searching for a
known run is among the most common reasons to open this panel and there was no
way to do it.

Three surfaces now, because they are three acts: pick a Flow type, find runs,
read the list. Stacked bare controls read as one long form and the list looked
like a fourth field.

`in` stays out. It needs a comma convention explained in placeholder text and a
second value control as soon as anybody wants two ranges.
The scope sentence listed every indexed Attribute above every list. The
Attribute picker inside Advanced search already names them, in the same words
from the same contract, so the sentence restated the control below it.

"Ask again" re-fetched with the filters last applied while Search re-fetches
with the filters on screen. Two buttons for one request, and the pair could
disagree: edit a control, press Ask again, and the list answers the old
question while the form shows the new one. Search is always visible in the same
panel, so it is the single trigger now. Stopping a run still refreshes the list.

Removes what the deleted FilterBuilder and FlowListing left behind:
`describeFilters` and its phrase table, `QUEUE_COPY.scope`, `unfilteredHint` and
`onThisPage` were reachable only from one test, which is dead product code with
a test holding it up. The liveness lines stay — a stale list still says so.
@zzheng-aiplay zzheng-aiplay reopened this Sep 20, 2026
@zzheng-aiplay zzheng-aiplay changed the title Zzheng/run and work queue modes Run and Inbox modes for Dex Web v2 Sep 20, 2026
Adding a second human gate stopped four agentic e2e tests at a wait nobody
answered, so each burned its full minute in WaitForFlow and reported only
"context deadline exceeded". They now confirm the message before waiting for
completion.

Both gates publish through the same gate-request-key and the drafting Step mints
a fresh one, so the helper waits for the first key that is not the approval key
just answered rather than assuming an order. An unknown billing effect gives up
at NonConvergenceStep without drafting a message, which is why that one case
stayed green and why the table flags which cases message the customer.

The visualizer test pinned the agentic Flow's Actions at two and its first
Indexed Attribute positionally. The Action list now names all four. The Indexed
Attributes are checked as a keyed map, because reading index 0 made an unrelated
declaration able to break an assertion about case-status — which is exactly what
happened when customer-email sorted ahead of it.
@zzheng-aiplay

Copy link
Copy Markdown
Collaborator

Reopened with the review feedback addressed. Since the close:

  • Run search, restored and simplified. Status and time by default; exact run id and one attribute comparison behind Advanced search. Operators come from the index type, so the UI cannot compose a query the server rejects — contains only for fulltext, ordering only for int, double and datetime. Only declared Indexed Attributes, never payloads, inputs, outputs or history. No multi-row builder. Run-id lookup is back; the earlier version had lost it.
  • Multiple human-actionable Steps per run. The refund example gained a confirm-or-rewrite gate for the customer message, so this is exercised by a real flow. Actionable Steps are derived from the graph rather than declared, and the drawer advances to the next pending gate on completion instead of closing.
  • Existing interaction model preserved. Run view, Process Canvas and Inbox are not redesigned.

Also fixed along the way: Canvas edges were invisible because the stroke-width rule lost to @xyflow/react's stylesheet at equal specificity, and group region merging bounded its passes by a shrinking length, so it silently stopped after about half the merges available in any flow.

CI is green (33 pass, 1 skipping). The description above is rewritten in full, including the gaps I deliberately left out — the role contract is not in this branch, so Inbox is still a run list rather than a participant view.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants