Skip to content

Commit b143546

Browse files
fahchenclaude
andcommitted
fix(dashboard): replay enabled state derived from marking, drop REPLAY chip
Two related issues on the EnactmentDetail replay flow: 1. Enabled-edge highlight during replay was wrong The previous fix (f94e507) made the highlight "sticky" — accumulated across replay steps. User says that's still wrong: at version v the diagram should show edges/transitions actually enabled AT v's derived marking, not the live count and not a cumulative union. Backend: derive_replay/2 now calls EnabledBindingElements.Computation.list(transition, runtime_cpnet, markings_map) for each transition against the derived marking and returns enabled_transitions in the :replay_to_version reply. The public surface CLAUDE.md names ("Given marking + transition, enumerates every firing binding. Core Petri-net semantics"); composed with Runner.RuntimeCpnet.from_definition/1 + markings map keyed by Place.name(). Same shape BindingInspector already uses. No new private reach. Frontend: stickyEnabledEdgeIds state + union effect deleted. ReplayToVersionReply gains enabled_transitions?: readonly string[]. replayEnabledTransitions state populates from each reply; derived into replayEnabledEdgeIds (p_to_t arcs whose transition is in the set). Live mode passes enabledEdgeIds=undefined so NetDiagram's internal enabled_count>0 path runs; replay passes the derived set exactly. 2. REPLAY chip in page header dropped The "REPLAY · v6" chip was redundant now that TimelineScrubber's own status line ("Replay · v6 of v7") and the new jump-to-live button (c0117f6) make the replay state self-evident. StateBadge's replay branch removed, the page no longer threads replayState into the byline. Tests: - enactment_detail_store_test: SimpleSequenceWorkflow case asserts v=0 → enabled_transitions == ["pass"]; v=1 → []; the single transition flips disabled after firing. - EnactmentDetailPage.test: NetDiagram stub forwards enabledEdgeIds as data-enabled-edges ("live" sentinel when undefined). New cases pin live=undefined-sentinel; replay reply wires through to the prop; empty enabled set renders "[]". The REPLAY-chip test rewritten as a negative-presence assertion. mix 175/176 (1 pre-existing telemetry-bridge flake reproduces on baseline); pnpm typecheck + 169/169 vitest + build clean; lib/coloured_flow byte-identical. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c0117f6 commit b143546

5 files changed

Lines changed: 152 additions & 68 deletions

File tree

dashboard/lib/coloured_flow_dashboard_web/stores/enactment_detail_store.ex

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStore do
129129
use Musubi.Store, root: true
130130

131131
alias ColouredFlow.Definition.ColouredPetriNet
132+
alias ColouredFlow.EnabledBindingElements.Computation, as: EnabledBindingElements
132133
alias ColouredFlow.Enactment.BindingElement
133134
alias ColouredFlow.Enactment.Marking
134135
alias ColouredFlow.Enactment.Occurrence
@@ -138,6 +139,7 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStore do
138139
alias ColouredFlow.Runner.Enactment.CatchingUp
139140
alias ColouredFlow.Runner.Enactment.Snapshot
140141
alias ColouredFlow.Runner.Enactment.Workitem, as: RunnerWorkitem
142+
alias ColouredFlow.Runner.RuntimeCpnet
141143
alias ColouredFlow.Runner.Storage
142144
alias ColouredFlow.Runner.Storage.Schemas
143145
alias ColouredFlow.Runner.Worklist.WorkitemStream
@@ -256,6 +258,7 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStore do
256258
reply do
257259
field :code, :ok | :invalid_version | :runner_error
258260
field :markings, list(ColouredFlowDashboardWeb.Views.MarkingRow.t())
261+
field :enabled_transitions, list(String.t())
259262
field :replay_state, ColouredFlowDashboardWeb.Views.ReplayState.t() | nil
260263
field :available_max_version, integer() | nil
261264
field :snapshot_floor, integer() | nil
@@ -465,7 +468,7 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStore do
465468
case coerce_version(version_raw) do
466469
{:ok, version} ->
467470
case derive_replay(socket, version) do
468-
{:ok, marking_rows, replay_state, marking_index} ->
471+
{:ok, marking_rows, enabled_transitions, replay_state, marking_index} ->
469472
socket =
470473
socket
471474
|> assign(:replay_marking_index, marking_index)
@@ -475,6 +478,7 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStore do
475478
reply = %{
476479
code: :ok,
477480
markings: marking_rows,
481+
enabled_transitions: enabled_transitions,
478482
replay_state: replay_state,
479483
available_max_version: socket.assigns.summary.version_range.max,
480484
snapshot_floor: current_snapshot_floor(socket.assigns.enactment_id)
@@ -485,7 +489,12 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStore do
485489
{:error, reason, info} ->
486490
reply =
487491
Map.merge(
488-
%{code: :invalid_version, markings: [], replay_state: nil},
492+
%{
493+
code: :invalid_version,
494+
markings: [],
495+
enabled_transitions: [],
496+
replay_state: nil
497+
},
489498
replay_error_fields(reason, info, socket)
490499
)
491500

@@ -496,6 +505,7 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStore do
496505
reply = %{
497506
code: :invalid_version,
498507
markings: [],
508+
enabled_transitions: [],
499509
replay_state: nil,
500510
available_max_version: socket.assigns.summary.version_range.max,
501511
snapshot_floor: current_snapshot_floor(socket.assigns.enactment_id)
@@ -717,11 +727,12 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStore do
717727
{:error, :above_max, %{available_max_version: available_max}}
718728

719729
true ->
720-
do_derive_replay(enactment_id, target_version)
730+
cpnet = cached_cpnet(enactment_id, socket.assigns.flow_cache)
731+
do_derive_replay(enactment_id, target_version, cpnet)
721732
end
722733
end
723734

724-
defp do_derive_replay(enactment_id, target_version) do
735+
defp do_derive_replay(enactment_id, target_version, cpnet) do
725736
# Pick the nearest base ≤ target_version. The persisted snapshot is the
726737
# fast path for target ≥ snapshot_version; for any earlier target we
727738
# MUST start from `Storage.get_initial_markings/1` and replay 1..target
@@ -747,17 +758,37 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStore do
747758
{_steps, derived} = CatchingUp.apply(initial_markings, occurrences)
748759
marking_rows = Enum.map(derived, &marking_row/1)
749760
marking_index = build_marking_index(marking_rows)
761+
enabled_transitions = compute_enabled_transitions(cpnet, derived)
750762

751763
replay_state = %ReplayState{
752764
version: target_version,
753765
derived_at: DateTime.to_iso8601(DateTime.utc_now())
754766
}
755767

756-
{:ok, marking_rows, replay_state, marking_index}
768+
{:ok, marking_rows, enabled_transitions, replay_state, marking_index}
757769
rescue
758770
error -> {:error, :runner_error, %{message: Exception.message(error)}}
759771
end
760772

773+
# Enabled-transition set at the derived marking. Delegates to
774+
# `ColouredFlow.EnabledBindingElements.Computation.list/3` (public surface
775+
# named in the main repo's CLAUDE.md) so the dashboard does not duplicate
776+
# engine semantics. Returns transition names whose enabled-binding-elements
777+
# set is non-empty against `markings`.
778+
defp compute_enabled_transitions(nil, _markings), do: []
779+
780+
defp compute_enabled_transitions(%ColouredPetriNet{} = cpnet, markings)
781+
when is_list(markings) do
782+
runtime_cpnet = RuntimeCpnet.from_definition(cpnet)
783+
markings_map = Map.new(markings, fn %Marking{place: p} = m -> {p, m} end)
784+
785+
for transition <- cpnet.transitions,
786+
EnabledBindingElements.list(transition, runtime_cpnet, markings_map) != [],
787+
do: to_string(transition.name)
788+
rescue
789+
_error -> []
790+
end
791+
761792
defp replay_error_fields(:above_max, %{available_max_version: max}, socket) do
762793
%{
763794
available_max_version: max,

dashboard/test/coloured_flow_dashboard_web/stores/enactment_detail_store_test.exs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,11 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStoreTest do
926926

927927
refute Enum.any?(reply.markings, fn row -> to_string(row.place) == "output" end)
928928

929+
# `pass` is enabled at v=0 against the initial marking — the runner's
930+
# `EnabledBindingElements.list/3` is the source of truth, so the reply
931+
# carries the engine-derived set, not the live `enabled_count`.
932+
assert reply.enabled_transitions == ["pass"]
933+
929934
# Summary.replay_state mirrors the reply so the SPA can flip into
930935
# replay chrome.
931936
summary = Musubi.Testing.assigns(page).summary
@@ -944,6 +949,12 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStoreTest do
944949

945950
refute Enum.any?(reply.markings, fn row -> to_string(row.place) == "input" end)
946951

952+
# At v=1 the only token has moved to `:output`, so `pass` has no input
953+
# binding and the engine reports no enabled transition. Asserts the
954+
# replay reply tracks the derived marking — not the live counts which
955+
# might still see `pass` enabled across a different enactment.
956+
assert reply.enabled_transitions == []
957+
947958
# The diagram derived view picks up the same place counts so the
948959
# token badges agree with the Markings tab in replay mode.
949960
diagram = Musubi.Testing.assigns(page).diagram

dashboard/ui/src/generated/musubi.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ declare namespace Musubi {
140140
reply: {
141141
code: "ok" | "invalid_version" | "runner_error"
142142
markings: ColouredFlowDashboardWeb.Views.MarkingRow[]
143+
enabled_transitions: string[]
143144
replay_state: ColouredFlowDashboardWeb.Views.ReplayState | null
144145
available_max_version: number | null
145146
snapshot_floor: number | null

dashboard/ui/src/routes/EnactmentDetailPage.test.tsx

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,23 @@ vi.mock("../components/NetDiagram", () => ({
131131
default: ({
132132
onSelectTransition,
133133
firingEdgeIds,
134-
firingDurationMs
134+
firingDurationMs,
135+
enabledEdgeIds
135136
}: {
136137
onSelectTransition?: (name: string) => void
137138
firingEdgeIds?: ReadonlySet<string>
138139
firingDurationMs?: number
140+
enabledEdgeIds?: ReadonlySet<string>
139141
}) => (
140142
<div
141143
data-testid="net-diagram-stub"
142144
data-firing-edges={JSON.stringify([...(firingEdgeIds ?? [])].sort())}
143145
data-firing-duration={String(firingDurationMs ?? "")}
146+
data-enabled-edges={
147+
enabledEdgeIds === undefined
148+
? "live"
149+
: JSON.stringify([...enabledEdgeIds].sort())
150+
}
144151
>
145152
<button
146153
type="button"
@@ -857,22 +864,86 @@ describe("EnactmentDetailPage", () => {
857864
}
858865
})
859866

860-
it("renders the REPLAY chip + Markings replay banner when summary.replay_state is set", () => {
867+
it("renders the Markings replay banner when summary.replay_state is set, without a separate page-header REPLAY pill", () => {
861868
const restore = mutateReplay(
862869
{ version: 1, derived_at: "2026-05-29T01:00:00Z" },
863870
{ min: 0, max: 3 }
864871
)
865872
try {
866873
renderRoute(<EnactmentDetailPage />)
867-
const chip = screen.getByTestId("state-badge-replay")
868-
expect(chip.textContent).toMatch(/REPLAY/)
869-
expect(chip.textContent).toMatch(/v1/)
874+
// The page-header REPLAY pill was dropped — TimelineScrubber's own
875+
// status line + the jump-to-live button now signal replay mode.
876+
expect(screen.queryByTestId("state-badge-replay")).toBeNull()
870877
expect(screen.getByTestId("markings-replay-banner")).toBeDefined()
871878
} finally {
872879
restore()
873880
}
874881
})
875882

883+
it("live mode passes undefined enabledEdgeIds so NetDiagram uses its internal enabled_count derivation", () => {
884+
renderRoute(<EnactmentDetailPage />)
885+
expect(
886+
screen.getByTestId("net-diagram-stub").dataset.enabledEdges
887+
).toBe("live")
888+
})
889+
890+
it("replay_to_version reply's enabled_transitions wires straight into NetDiagram's enabledEdgeIds", async () => {
891+
replayToVersionMock.mockResolvedValueOnce({
892+
code: "ok",
893+
markings: [],
894+
enabled_transitions: ["approve"],
895+
replay_state: { version: 1, derived_at: "2026-05-29T01:00:00Z" },
896+
available_max_version: 3,
897+
snapshot_floor: 0
898+
})
899+
const restore = mutateReplay(
900+
{ version: 1, derived_at: "2026-05-29T01:00:00Z" },
901+
{ min: 0, max: 3 }
902+
)
903+
try {
904+
renderRoute(<EnactmentDetailPage />)
905+
await act(async () => {
906+
fireEvent.click(screen.getByTestId("timeline-step-back"))
907+
})
908+
await waitFor(() => {
909+
const value = screen.getByTestId("net-diagram-stub").dataset.enabledEdges
910+
// Only the p_to_t arc of `approve` lights up — the seed diagram has
911+
// exactly one such edge (`pending → approve` at index 0).
912+
expect(value).toBe(JSON.stringify(["arc-p_to_t-pending-approve-0"]))
913+
})
914+
} finally {
915+
restore()
916+
}
917+
})
918+
919+
it("an empty enabled_transitions clears the replay edge highlight", async () => {
920+
replayToVersionMock.mockResolvedValueOnce({
921+
code: "ok",
922+
markings: [],
923+
enabled_transitions: [],
924+
replay_state: { version: 0, derived_at: "2026-05-29T01:00:00Z" },
925+
available_max_version: 3,
926+
snapshot_floor: 0
927+
})
928+
const restore = mutateReplay(
929+
{ version: 1, derived_at: "2026-05-29T01:00:00Z" },
930+
{ min: 0, max: 3 }
931+
)
932+
try {
933+
renderRoute(<EnactmentDetailPage />)
934+
await act(async () => {
935+
fireEvent.click(screen.getByTestId("timeline-step-back"))
936+
})
937+
await waitFor(() => {
938+
expect(
939+
screen.getByTestId("net-diagram-stub").dataset.enabledEdges
940+
).toBe("[]")
941+
})
942+
} finally {
943+
restore()
944+
}
945+
})
946+
876947
it("populates firingEdgeIds when version advances + clears after the firing duration", async () => {
877948
vi.useFakeTimers()
878949
const mut = sampleSnapshot as unknown as {

0 commit comments

Comments
 (0)