causal-inspector 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import type { SubjectChainMode } from "./types";
1
2
  import "./CausalInspector.css";
2
3
  export type CausalInspectorProps = {
3
4
  /** GraphQL endpoint URL (relative or absolute). Queries POST here, WS connects to {endpoint}/ws */
@@ -9,5 +10,11 @@ export type CausalInspectorProps = {
9
10
  };
10
11
  /** CSS class for the container */
11
12
  className?: string;
13
+ /** Pre-navigate to a specific entity's subject chain on mount */
14
+ initialSubject?: {
15
+ aggregateType: string;
16
+ aggregateId: string;
17
+ mode?: SubjectChainMode;
18
+ };
12
19
  };
13
- export declare function CausalInspector({ endpoint, fetchOptions, className, }: CausalInspectorProps): import("react/jsx-runtime").JSX.Element;
20
+ export declare function CausalInspector({ endpoint, fetchOptions, className, initialSubject, }: CausalInspectorProps): import("react/jsx-runtime").JSX.Element;
@@ -8,11 +8,12 @@ import { createInspectorEngine } from "./engines";
8
8
  import { useSelector, useDispatch } from "./machine";
9
9
  import { TimelinePane } from "./panes/TimelinePane";
10
10
  import { CausalTreePane } from "./panes/CausalTreePane";
11
+ import { SubjectChainPane } from "./panes/SubjectChainPane";
11
12
  import { CausalFlowPane } from "./panes/CausalFlowPane";
12
13
  import { LogsPane } from "./panes/LogsPane";
13
14
  import { AggregateTimelinePane } from "./panes/AggregateTimelinePane";
14
15
  import { WaterfallPane } from "./panes/WaterfallPane";
15
- import { CorrelationExplorerPane } from "./panes/CorrelationExplorerPane";
16
+ import { WorkflowExplorerPane } from "./panes/WorkflowExplorerPane";
16
17
  import { GlobalScrubber } from "./components/GlobalScrubber";
17
18
  import "./CausalInspector.css";
18
19
  // ── Transport ─────────────────────────────────────────────────
@@ -139,9 +140,14 @@ const PANE_REGISTRY = [
139
140
  render: () => _jsx(WaterfallPane, {}),
140
141
  },
141
142
  {
142
- name: "Correlations",
143
- component: "correlations",
144
- render: () => _jsx(CorrelationExplorerPane, {}),
143
+ name: "Workflows",
144
+ component: "workflows",
145
+ render: () => _jsx(WorkflowExplorerPane, {}),
146
+ },
147
+ {
148
+ name: "Subject Chain",
149
+ component: "subject-chain",
150
+ render: () => _jsx(SubjectChainPane, {}),
145
151
  },
146
152
  ];
147
153
  // ── Helpers ───────────────────────────────────────────────────
@@ -158,8 +164,9 @@ function findTab(model, component) {
158
164
  // ── InspectorLayout (inner component) ─────────────────────────
159
165
  function InspectorLayout() {
160
166
  const paneLayout = useSelector((s) => s.paneLayout);
161
- const flowCorrelationId = useSelector((s) => s.flowCorrelationId);
167
+ const flowWorkflowId = useSelector((s) => s.flowWorkflowId);
162
168
  const selectedSeq = useSelector((s) => s.selectedSeq);
169
+ const subjectType = useSelector((s) => s.subjectType);
163
170
  const dispatch = useDispatch();
164
171
  const modelRef = useRef(null);
165
172
  if (!modelRef.current) {
@@ -189,9 +196,13 @@ function InspectorLayout() {
189
196
  addTab("causal-tree", "Causal Tree");
190
197
  }, [selectedSeq, addTab]);
191
198
  useEffect(() => {
192
- if (flowCorrelationId)
199
+ if (flowWorkflowId)
193
200
  addTab("causal-flow", "Flow");
194
- }, [flowCorrelationId, addTab]);
201
+ }, [flowWorkflowId, addTab]);
202
+ useEffect(() => {
203
+ if (subjectType)
204
+ addTab("subject-chain", "Subject Chain");
205
+ }, [subjectType, addTab]);
195
206
  const factory = useCallback((node) => {
196
207
  const component = node.getComponent();
197
208
  const pane = PANE_REGISTRY.find((p) => p.component === component);
@@ -249,10 +260,22 @@ function InspectorLayout() {
249
260
  }
250
261
  // ── CausalInspector (public API) ──────────────────────────────
251
262
  const savedLayout = loadSavedLayout();
252
- export function CausalInspector({ endpoint, fetchOptions, className, }) {
263
+ export function CausalInspector({ endpoint, fetchOptions, className, initialSubject, }) {
253
264
  const transport = useMemo(() => createTransport(endpoint, fetchOptions),
254
265
  // eslint-disable-next-line react-hooks/exhaustive-deps
255
266
  [endpoint]);
256
267
  const createEngine = useMemo(() => createInspectorEngine(transport, storage), [transport]);
257
- return (_jsx("div", { className: `causal-inspector${className ? ` ${className}` : ""}`, children: _jsx(CausalInspectorProvider, { createEngine: createEngine, initialState: savedLayout ? { paneLayout: savedLayout } : undefined, children: _jsx(InspectorLayout, {}) }) }));
268
+ const initialState = useMemo(() => {
269
+ const base = savedLayout ? { paneLayout: savedLayout } : undefined;
270
+ if (!initialSubject)
271
+ return base;
272
+ return {
273
+ ...base,
274
+ subjectType: initialSubject.aggregateType,
275
+ subjectId: initialSubject.aggregateId,
276
+ subjectMode: initialSubject.mode ?? "both",
277
+ subjectChainLoading: true,
278
+ };
279
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
280
+ return (_jsx("div", { className: `causal-inspector${className ? ` ${className}` : ""}`, children: _jsx(CausalInspectorProvider, { createEngine: createEngine, initialState: initialState, children: _jsx(InspectorLayout, {}) }) }));
258
281
  }
@@ -0,0 +1,4 @@
1
+ import type { InspectorEffect } from "../types";
2
+ export declare function EffectList({ effects }: {
3
+ effects: InspectorEffect[];
4
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { ChevronDown, ChevronRight } from "lucide-react";
4
+ function EffectRow({ effect }) {
5
+ const [expanded, setExpanded] = useState(false);
6
+ const valueStr = JSON.stringify(effect.value, null, 2);
7
+ const preview = valueStr.length > 120 ? valueStr.slice(0, 120) + "…" : valueStr;
8
+ return (_jsxs("div", { className: "py-1", children: [_jsxs("button", { onClick: () => setExpanded((v) => !v), className: "flex items-start gap-1.5 w-full text-left group/effect", children: [_jsx("span", { className: "mt-0.5 text-muted-foreground/40 shrink-0", children: expanded ? _jsx(ChevronDown, { size: 10 }) : _jsx(ChevronRight, { size: 10 }) }), _jsx("span", { className: "text-[9px] font-mono text-indigo-400/70 shrink-0", children: effect.consumer }), _jsx("span", { className: "text-[9px] text-muted-foreground/40 shrink-0", children: "\u00B7" }), _jsx("span", { className: "text-[9px] font-mono text-foreground/70 shrink-0", children: effect.label }), !expanded && (_jsx("span", { className: "text-[9px] font-mono text-muted-foreground/50 truncate min-w-0", children: preview }))] }), expanded && (_jsx("pre", { className: "mt-1 ml-4 text-[9px] font-mono text-foreground/60 whitespace-pre-wrap break-all bg-white/[0.02] rounded p-1.5 border border-border/50", children: valueStr }))] }));
9
+ }
10
+ export function EffectList({ effects }) {
11
+ if (effects.length === 0) {
12
+ return (_jsx("div", { className: "py-1 text-[9px] text-muted-foreground/40 italic ml-3", children: "No effects" }));
13
+ }
14
+ return (_jsx("div", { className: "ml-3 border-l border-indigo-500/15 pl-2", children: effects.map((e, i) => (_jsx(EffectRow, { effect: e }, `${e.consumer}:${e.label}:${i}`))) }));
15
+ }
@@ -7,9 +7,9 @@ export function FilterBar() {
7
7
  return (_jsxs("div", { className: "flex flex-wrap items-center gap-2 px-3 py-2 border-b border-border", style: { background: "rgba(15, 15, 20, 0.6)", backdropFilter: "blur(8px)" }, children: [_jsxs("div", { className: "relative flex items-center", children: [_jsx(Search, { size: 12, className: "absolute left-2.5 text-muted-foreground pointer-events-none" }), _jsx("input", { type: "text", placeholder: "search events...", value: filters.search, onChange: (e) => dispatch({
8
8
  type: "ui/filter_changed",
9
9
  payload: { search: e.target.value },
10
- }), className: "pl-7 pr-2 py-1.5 text-xs rounded-md bg-background/50 border border-border text-foreground placeholder:text-muted-foreground w-64 focus:outline-none focus:ring-1 focus:ring-indigo-500/40 focus:border-indigo-500/30 transition-all" })] }), filters.correlationId && (_jsxs(_Fragment, { children: [_jsx("span", { className: "w-px h-4 bg-border" }), _jsxs("span", { className: "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-[10px] font-mono", children: [filters.correlationId.slice(0, 8), _jsx("button", { onClick: () => dispatch({
10
+ }), className: "pl-7 pr-2 py-1.5 text-xs rounded-md bg-background/50 border border-border text-foreground placeholder:text-muted-foreground w-64 focus:outline-none focus:ring-1 focus:ring-indigo-500/40 focus:border-indigo-500/30 transition-all" })] }), filters.workflowId && (_jsxs(_Fragment, { children: [_jsx("span", { className: "w-px h-4 bg-border" }), _jsxs("span", { className: "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-[10px] font-mono", children: [filters.workflowId.slice(0, 8), _jsx("button", { onClick: () => dispatch({
11
11
  type: "ui/filter_changed",
12
- payload: { correlationId: null },
12
+ payload: { workflowId: null },
13
13
  }), className: "hover:text-foreground transition-colors", children: _jsx(X, { size: 10 }) })] })] })), filters.aggregateKey && (_jsxs(_Fragment, { children: [_jsx("span", { className: "w-px h-4 bg-border" }), _jsxs("span", { className: "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-teal-500/10 border border-teal-500/20 text-teal-400 text-[10px] font-mono", children: [filters.aggregateKey.split(":")[0], ":", filters.aggregateKey.split(":").slice(1).join(":").slice(0, 8), _jsx("button", { onClick: () => dispatch({
14
14
  type: "ui/filter_changed",
15
15
  payload: { aggregateKey: null },
@@ -58,15 +58,15 @@ function RangeSlider({ seqs, start, end, onStartChange, onEndChange, }) {
58
58
  // ---------------------------------------------------------------------------
59
59
  // Context label
60
60
  // ---------------------------------------------------------------------------
61
- function getContextLabel(flowCorrelationId, flowSelection) {
62
- if (!flowCorrelationId)
61
+ function getContextLabel(flowWorkflowId, flowSelection) {
62
+ if (!flowWorkflowId)
63
63
  return "All events";
64
64
  if (flowSelection) {
65
65
  if (flowSelection.kind === "event-type")
66
66
  return `${flowSelection.name} events`;
67
67
  return `Reactor ${flowSelection.reactorId}`;
68
68
  }
69
- return `Flow ${flowCorrelationId.slice(0, 8)}`;
69
+ return `Flow ${flowWorkflowId.slice(0, 8)}`;
70
70
  }
71
71
  // ---------------------------------------------------------------------------
72
72
  // GlobalScrubber
@@ -76,12 +76,12 @@ export function GlobalScrubber() {
76
76
  const scrubberEnd = useSelector((s) => s.scrubberEnd);
77
77
  const playing = useSelector((s) => s.scrubberPlaying);
78
78
  const speed = useSelector((s) => s.scrubberSpeed);
79
- const flowCorrelationId = useSelector((s) => s.flowCorrelationId);
79
+ const flowWorkflowId = useSelector((s) => s.flowWorkflowId);
80
80
  const flowSelection = useSelector((s) => s.flowSelection);
81
81
  const flowData = useSelector((s) => s.flowData);
82
82
  const events = useSelector((s) => s.events);
83
83
  const dispatch = useDispatch();
84
- const seqs = useMemo(() => getScrubberSequence({ flowCorrelationId, flowData, flowSelection, events }), [flowCorrelationId, flowData, flowSelection, events]);
84
+ const seqs = useMemo(() => getScrubberSequence({ flowWorkflowId, flowData, flowSelection, events }), [flowWorkflowId, flowData, flowSelection, events]);
85
85
  const endVal = scrubberEnd ?? (seqs[seqs.length - 1] ?? 0);
86
86
  const isAtEnd = scrubberEnd == null || (seqs.length > 0 && scrubberEnd >= seqs[seqs.length - 1]);
87
87
  // Count events in range
@@ -139,7 +139,7 @@ export function GlobalScrubber() {
139
139
  const disabled = seqs.length < 2;
140
140
  const speedLabel = speed <= 50 ? "4x" : speed <= 150 ? "2x" : speed <= 300 ? "1x" : "0.5x";
141
141
  const btnClass = `p-1.5 rounded-md transition-all duration-150 ${disabled ? "text-muted-foreground/20 cursor-default" : "text-muted-foreground/60 hover:text-foreground hover:bg-white/[0.05]"}`;
142
- const contextLabel = getContextLabel(flowCorrelationId, flowSelection);
142
+ const contextLabel = getContextLabel(flowWorkflowId, flowSelection);
143
143
  return (_jsxs("div", { className: "flex items-center gap-2 px-3 py-2 border-t border-border shrink-0", style: { background: "rgba(15, 15, 20, 0.6)", backdropFilter: "blur(8px)" }, children: [_jsx("span", { className: "text-[9px] font-medium text-muted-foreground/40 uppercase tracking-wider shrink-0 max-w-[120px] truncate", title: contextLabel, children: contextLabel }), _jsx("button", { onClick: disabled ? undefined : reset, className: btnClass, title: "Reset to start", children: _jsx(RotateCcw, { size: 12 }) }), _jsx("button", { onClick: disabled ? undefined : stepBack, className: btnClass, title: "Step back", children: _jsx(SkipBack, { size: 12 }) }), _jsx("button", { onClick: disabled ? undefined : togglePlay, className: `p-1.5 rounded-md transition-all duration-150 ${disabled
144
144
  ? "text-muted-foreground/20 cursor-default"
145
145
  : playing
@@ -1,4 +1,4 @@
1
- import { INSPECTOR_EVENTS, INSPECTOR_CAUSAL_TREE, INSPECTOR_CAUSAL_FLOW, INSPECTOR_CORRELATIONS, INSPECTOR_REACTOR_DEPENDENCIES, INSPECTOR_AGGREGATE_KEYS, INSPECTOR_AGGREGATE_LIFECYCLE, INSPECTOR_REACTOR_LOGS_BY_CORRELATION, INSPECTOR_REACTOR_DESCRIPTIONS, INSPECTOR_REACTOR_DESCRIPTION_SNAPSHOTS, INSPECTOR_AGGREGATE_TIMELINE, INSPECTOR_REACTOR_OUTCOMES, } from "../queries";
1
+ import { INSPECTOR_EVENTS, INSPECTOR_CAUSAL_TREE, INSPECTOR_CAUSAL_FLOW, INSPECTOR_WORKFLOWS, INSPECTOR_REACTOR_DEPENDENCIES, INSPECTOR_AGGREGATE_KEYS, INSPECTOR_AGGREGATE_LIFECYCLE, INSPECTOR_REACTOR_LOGS_BY_WORKFLOW, INSPECTOR_REACTOR_DESCRIPTIONS, INSPECTOR_REACTOR_DESCRIPTION_SNAPSHOTS, INSPECTOR_AGGREGATE_TIMELINE, INSPECTOR_REACTOR_OUTCOMES, INSPECTOR_REACTOR_ATTEMPTS, INSPECTOR_SUBJECT_CHAIN, INSPECTOR_EFFECTS_FOR_EVENT, } from "../queries";
2
2
  /**
3
3
  * Query engine — fetches data in response to state transitions.
4
4
  *
@@ -8,10 +8,11 @@ import { INSPECTOR_EVENTS, INSPECTOR_CAUSAL_TREE, INSPECTOR_CAUSAL_FLOW, INSPECT
8
8
  export const createQueryEngine = (transport) => {
9
9
  return (dispatch, getState) => {
10
10
  let flowPollTimer = null;
11
- let correlationPollTimer = null;
11
+ let workflowPollTimer = null;
12
12
  // Stale-response guards
13
13
  let activeCausalSeq = null;
14
- let activeFlowCorrelationId = null;
14
+ let activeFlowWorkflowId = null;
15
+ let activeSubjectKey = null;
15
16
  const fetchEvents = async () => {
16
17
  const state = getState();
17
18
  const cursor = state.events.length > 0
@@ -22,7 +23,7 @@ export const createQueryEngine = (transport) => {
22
23
  limit: 50,
23
24
  cursor,
24
25
  search: state.filters.search || undefined,
25
- correlationId: state.filters.correlationId || undefined,
26
+ workflowId: state.filters.workflowId || undefined,
26
27
  aggregateKey: state.filters.aggregateKey || undefined,
27
28
  });
28
29
  dispatch({
@@ -52,11 +53,11 @@ export const createQueryEngine = (transport) => {
52
53
  console.error("[causal-inspector] fetch causal tree failed:", e);
53
54
  }
54
55
  };
55
- const fetchFlow = async (correlationId) => {
56
- activeFlowCorrelationId = correlationId;
56
+ const fetchFlow = async (workflowId) => {
57
+ activeFlowWorkflowId = workflowId;
57
58
  try {
58
- const data = await transport.query(INSPECTOR_CAUSAL_FLOW, { correlationId });
59
- if (activeFlowCorrelationId !== correlationId)
59
+ const data = await transport.query(INSPECTOR_CAUSAL_FLOW, { workflowId });
60
+ if (activeFlowWorkflowId !== workflowId)
60
61
  return; // stale
61
62
  dispatch({
62
63
  type: "events/flow_loaded",
@@ -67,80 +68,88 @@ export const createQueryEngine = (transport) => {
67
68
  console.error("[causal-inspector] fetch flow failed:", e);
68
69
  }
69
70
  };
70
- const fetchFlowMetadata = async (correlationId) => {
71
+ const fetchFlowMetadata = async (workflowId) => {
71
72
  try {
72
- const [descData, snapshotData, aggTimelineData, outcomeData] = await Promise.all([
73
- transport.query(INSPECTOR_REACTOR_DESCRIPTIONS, { correlationId }),
74
- transport.query(INSPECTOR_REACTOR_DESCRIPTION_SNAPSHOTS, { correlationId }),
75
- transport.query(INSPECTOR_AGGREGATE_TIMELINE, { correlationId }),
76
- transport.query(INSPECTOR_REACTOR_OUTCOMES, { correlationId }),
73
+ const [descData, snapshotData, aggTimelineData, outcomeData, attemptData] = await Promise.all([
74
+ transport.query(INSPECTOR_REACTOR_DESCRIPTIONS, { workflowId }),
75
+ transport.query(INSPECTOR_REACTOR_DESCRIPTION_SNAPSHOTS, { workflowId }),
76
+ transport.query(INSPECTOR_AGGREGATE_TIMELINE, { workflowId }),
77
+ transport.query(INSPECTOR_REACTOR_OUTCOMES, { workflowId }),
78
+ transport.query(INSPECTOR_REACTOR_ATTEMPTS, { workflowId }),
77
79
  ]);
78
- if (activeFlowCorrelationId !== correlationId)
80
+ if (activeFlowWorkflowId !== workflowId)
79
81
  return; // stale
80
82
  dispatch({
81
83
  type: "events/descriptions_loaded",
82
84
  payload: {
83
- correlationId,
85
+ workflowId,
84
86
  descriptions: descData.inspectorReactorDescriptions,
85
87
  },
86
88
  });
87
89
  dispatch({
88
90
  type: "events/description_snapshots_loaded",
89
91
  payload: {
90
- correlationId,
92
+ workflowId,
91
93
  snapshots: snapshotData.inspectorReactorDescriptionSnapshots,
92
94
  },
93
95
  });
94
96
  dispatch({
95
97
  type: "events/aggregate_timeline_loaded",
96
98
  payload: {
97
- correlationId,
99
+ workflowId,
98
100
  entries: aggTimelineData.inspectorAggregateTimeline,
99
101
  },
100
102
  });
101
103
  dispatch({
102
104
  type: "events/outcomes_loaded",
103
105
  payload: {
104
- correlationId,
106
+ workflowId,
105
107
  outcomes: outcomeData.inspectorReactorOutcomes,
106
108
  },
107
109
  });
110
+ dispatch({
111
+ type: "events/attempts_loaded",
112
+ payload: {
113
+ workflowId,
114
+ attempts: attemptData.inspectorReactorAttempts,
115
+ },
116
+ });
108
117
  }
109
118
  catch (e) {
110
119
  console.error("[causal-inspector] fetch flow metadata failed:", e);
111
120
  }
112
121
  };
113
- const fetchLogs = async (correlationId) => {
122
+ const fetchLogs = async (workflowId) => {
114
123
  try {
115
- const data = await transport.query(INSPECTOR_REACTOR_LOGS_BY_CORRELATION, { correlationId });
116
- dispatch({ type: "events/logs_loaded", payload: data.inspectorReactorLogsByCorrelation });
124
+ const data = await transport.query(INSPECTOR_REACTOR_LOGS_BY_WORKFLOW, { workflowId });
125
+ dispatch({ type: "events/logs_loaded", payload: data.inspectorReactorLogsByWorkflow });
117
126
  }
118
127
  catch (e) {
119
128
  console.error("[causal-inspector] fetch logs failed:", e);
120
129
  }
121
130
  };
122
- let correlationCursor = null;
123
- const fetchCorrelations = async (opts) => {
131
+ let workflowCursor = null;
132
+ const fetchWorkflows = async (opts) => {
124
133
  const append = opts?.append ?? false;
125
- const cursor = append ? correlationCursor : undefined;
134
+ const cursor = append ? workflowCursor : undefined;
126
135
  try {
127
- const data = await transport.query(INSPECTOR_CORRELATIONS, {
136
+ const data = await transport.query(INSPECTOR_WORKFLOWS, {
128
137
  search: opts?.search || undefined,
129
138
  limit: 50,
130
139
  cursor: cursor || undefined,
131
140
  });
132
- correlationCursor = data.inspectorCorrelations.nextCursor;
141
+ workflowCursor = data.inspectorWorkflows.nextCursor;
133
142
  dispatch({
134
- type: "events/correlations_loaded",
143
+ type: "events/workflows_loaded",
135
144
  payload: {
136
- correlations: data.inspectorCorrelations.correlations,
137
- hasMore: data.inspectorCorrelations.nextCursor != null,
145
+ workflows: data.inspectorWorkflows.workflows,
146
+ hasMore: data.inspectorWorkflows.nextCursor != null,
138
147
  append,
139
148
  },
140
149
  });
141
150
  }
142
151
  catch (e) {
143
- console.error("[causal-inspector] fetch correlations failed:", e);
152
+ console.error("[causal-inspector] fetch workflows failed:", e);
144
153
  }
145
154
  };
146
155
  const fetchReactorDependencies = async () => {
@@ -179,10 +188,54 @@ export const createQueryEngine = (transport) => {
179
188
  console.error("[causal-inspector] fetch aggregate lifecycle failed:", e);
180
189
  }
181
190
  };
182
- const startFlowPolling = (correlationId) => {
191
+ const fetchSubjectChain = async (aggregateType, aggregateId, mode, cursor) => {
192
+ const key = `${aggregateType}:${aggregateId}:${mode}`;
193
+ activeSubjectKey = key;
194
+ try {
195
+ const data = await transport.query(INSPECTOR_SUBJECT_CHAIN, {
196
+ aggregateType,
197
+ aggregateId,
198
+ mode: mode.toUpperCase(),
199
+ limit: 50,
200
+ cursor: cursor ?? undefined,
201
+ });
202
+ if (activeSubjectKey !== key)
203
+ return; // navigated away
204
+ const page = data.inspectorSubjectChain;
205
+ dispatch({
206
+ type: "events/subject_chain_loaded",
207
+ payload: {
208
+ events: page.events,
209
+ hasMore: page.nextCursor != null,
210
+ cursor: page.nextCursor,
211
+ depthCapped: page.depthCapReached,
212
+ append: cursor != null,
213
+ },
214
+ });
215
+ }
216
+ catch (e) {
217
+ console.error("[causal-inspector] fetch subject chain failed:", e);
218
+ }
219
+ };
220
+ const fetchEventEffects = async (eventId) => {
221
+ const state = getState();
222
+ if (eventId in state.expandedEffects)
223
+ return; // already loaded
224
+ try {
225
+ const data = await transport.query(INSPECTOR_EFFECTS_FOR_EVENT, { eventId });
226
+ dispatch({
227
+ type: "events/event_effects_loaded",
228
+ payload: { eventId, effects: data.inspectorEffectsForEvent },
229
+ });
230
+ }
231
+ catch (e) {
232
+ console.error("[causal-inspector] fetch event effects failed:", e);
233
+ }
234
+ };
235
+ const startFlowPolling = (workflowId) => {
183
236
  stopFlowPolling();
184
- fetchFlowMetadata(correlationId);
185
- flowPollTimer = setInterval(() => fetchFlowMetadata(correlationId), 5000);
237
+ fetchFlowMetadata(workflowId);
238
+ flowPollTimer = setInterval(() => fetchFlowMetadata(workflowId), 5000);
186
239
  };
187
240
  const stopFlowPolling = () => {
188
241
  if (flowPollTimer) {
@@ -190,27 +243,33 @@ export const createQueryEngine = (transport) => {
190
243
  flowPollTimer = null;
191
244
  }
192
245
  };
193
- const stopCorrelationPolling = () => {
194
- if (correlationPollTimer) {
195
- clearInterval(correlationPollTimer);
196
- correlationPollTimer = null;
246
+ const stopWorkflowPolling = () => {
247
+ if (workflowPollTimer) {
248
+ clearInterval(workflowPollTimer);
249
+ workflowPollTimer = null;
197
250
  }
198
251
  };
199
252
  // Initial load
200
253
  fetchEvents();
201
- fetchCorrelations();
254
+ fetchWorkflows();
202
255
  fetchReactorDependencies();
203
256
  fetchAggregateKeys();
204
257
  return {
205
258
  handleEvent: (event, curr, prev) => {
206
259
  // ── State-reactive: navigation transitions ──
207
- if (curr.flowCorrelationId !== prev.flowCorrelationId) {
208
- if (curr.flowCorrelationId) {
260
+ // Subject changed fetch first page
261
+ if (curr.subjectType !== prev.subjectType || curr.subjectId !== prev.subjectId) {
262
+ if (curr.subjectType && curr.subjectId) {
263
+ fetchSubjectChain(curr.subjectType, curr.subjectId, curr.subjectMode, null);
264
+ }
265
+ }
266
+ if (curr.flowWorkflowId !== prev.flowWorkflowId) {
267
+ if (curr.flowWorkflowId) {
209
268
  // Flow opened
210
- fetchFlow(curr.flowCorrelationId);
211
- startFlowPolling(curr.flowCorrelationId);
212
- fetchLogs(curr.flowCorrelationId);
213
- stopCorrelationPolling();
269
+ fetchFlow(curr.flowWorkflowId);
270
+ startFlowPolling(curr.flowWorkflowId);
271
+ fetchLogs(curr.flowWorkflowId);
272
+ stopWorkflowPolling();
214
273
  }
215
274
  else {
216
275
  // Flow closed
@@ -228,23 +287,45 @@ export const createQueryEngine = (transport) => {
228
287
  case "ui/filter_changed":
229
288
  fetchEvents();
230
289
  break;
231
- case "ui/load_more_correlations_requested":
232
- fetchCorrelations({ append: true });
290
+ case "ui/load_more_workflows_requested":
291
+ fetchWorkflows({ append: true });
233
292
  break;
234
- case "ui/correlations_requested":
235
- correlationCursor = null;
236
- fetchCorrelations({ search: event.payload.search });
237
- stopCorrelationPolling();
238
- correlationPollTimer = setInterval(() => fetchCorrelations({ search: event.payload.search }), 5000);
293
+ case "ui/workflows_requested":
294
+ workflowCursor = null;
295
+ fetchWorkflows({ search: event.payload.search });
296
+ stopWorkflowPolling();
297
+ workflowPollTimer = setInterval(() => fetchWorkflows({ search: event.payload.search }), 5000);
239
298
  break;
240
299
  case "ui/aggregate_lifecycle_requested":
241
300
  fetchAggregateLifecycle(event.payload.aggregateKey);
242
301
  break;
302
+ case "ui/subject_selected": {
303
+ const { aggregateType, aggregateId, mode = "both" } = event.payload;
304
+ fetchSubjectChain(aggregateType, aggregateId, mode, null);
305
+ break;
306
+ }
307
+ case "ui/subject_mode_changed": {
308
+ const state = getState();
309
+ if (state.subjectType && state.subjectId) {
310
+ fetchSubjectChain(state.subjectType, state.subjectId, event.payload.mode, null);
311
+ }
312
+ break;
313
+ }
314
+ case "ui/subject_chain_load_more": {
315
+ const state = getState();
316
+ if (state.subjectType && state.subjectId) {
317
+ fetchSubjectChain(state.subjectType, state.subjectId, state.subjectMode, state.subjectChainCursor);
318
+ }
319
+ break;
320
+ }
321
+ case "ui/event_effects_requested":
322
+ fetchEventEffects(event.payload.eventId);
323
+ break;
243
324
  }
244
325
  },
245
326
  dispose: () => {
246
327
  stopFlowPolling();
247
- stopCorrelationPolling();
328
+ stopWorkflowPolling();
248
329
  },
249
330
  };
250
331
  };
@@ -60,7 +60,7 @@ export const createScrubberEngine = (dispatch, getState) => {
60
60
  start();
61
61
  }
62
62
  // Flow changed → stop playback
63
- if (curr.flowCorrelationId !== prev.flowCorrelationId) {
63
+ if (curr.flowWorkflowId !== prev.flowWorkflowId) {
64
64
  stop();
65
65
  }
66
66
  },
@@ -4,8 +4,11 @@ import type { InspectorState } from "../state";
4
4
  /**
5
5
  * URL engine — keeps the browser URL in sync with navigation state.
6
6
  *
7
- * For user-initiated actions (ui/flow_opened, etc.), the reducer updates
8
- * state directly. This engine just writes the URL as a side effect.
7
+ * Mutual exclusion: `?subject=` and `?workflow=` never coexist in the URL.
8
+ * Navigating to a subject clears the workflow param and vice versa.
9
+ *
10
+ * For user-initiated actions, the reducer updates state directly.
11
+ * This engine writes the URL as a side effect.
9
12
  *
10
13
  * For browser-initiated navigation (back/forward), this engine dispatches
11
14
  * location/changed so the reducer can update state from the URL.
@@ -1,44 +1,57 @@
1
1
  function parseUrl() {
2
2
  const params = new URLSearchParams(window.location.search);
3
+ const subject = params.get("subject");
4
+ const subjectModeRaw = params.get("subjectMode");
5
+ const subjectMode = subjectModeRaw === "stream" || subjectModeRaw === "descendants" || subjectModeRaw === "both"
6
+ ? subjectModeRaw
7
+ : null;
8
+ if (subject) {
9
+ return { workflowId: null, handler: null, subject, subjectMode };
10
+ }
3
11
  return {
4
- correlationId: params.get("correlation"),
12
+ workflowId: params.get("workflow"),
5
13
  handler: params.get("handler"),
14
+ subject: null,
15
+ subjectMode: null,
6
16
  };
7
17
  }
8
- function buildSearch(correlationId, handler) {
9
- const params = new URLSearchParams(window.location.search);
10
- if (correlationId)
11
- params.set("correlation", correlationId);
12
- else {
13
- params.delete("correlation");
14
- params.delete("handler");
18
+ function buildSearch(workflowId, handler) {
19
+ const params = new URLSearchParams();
20
+ if (workflowId) {
21
+ params.set("workflow", workflowId);
22
+ if (handler)
23
+ params.set("handler", handler);
15
24
  }
16
- if (handler && correlationId)
17
- params.set("handler", handler);
18
- else
19
- params.delete("handler");
20
25
  const search = params.toString();
21
26
  return search ? `${window.location.pathname}?${search}` : window.location.pathname;
22
27
  }
28
+ function buildSubjectSearch(aggregateType, aggregateId, mode) {
29
+ const params = new URLSearchParams();
30
+ params.set("subject", `${aggregateType}:${aggregateId}`);
31
+ if (mode !== "both")
32
+ params.set("subjectMode", mode);
33
+ return `${window.location.pathname}?${params.toString()}`;
34
+ }
23
35
  /**
24
36
  * URL engine — keeps the browser URL in sync with navigation state.
25
37
  *
26
- * For user-initiated actions (ui/flow_opened, etc.), the reducer updates
27
- * state directly. This engine just writes the URL as a side effect.
38
+ * Mutual exclusion: `?subject=` and `?workflow=` never coexist in the URL.
39
+ * Navigating to a subject clears the workflow param and vice versa.
40
+ *
41
+ * For user-initiated actions, the reducer updates state directly.
42
+ * This engine writes the URL as a side effect.
28
43
  *
29
44
  * For browser-initiated navigation (back/forward), this engine dispatches
30
45
  * location/changed so the reducer can update state from the URL.
31
46
  */
32
47
  export const createUrlEngine = (dispatch, _getState) => {
33
- // Popstate — browser back/forward
34
48
  const onPopState = () => {
35
49
  dispatch({ type: "location/changed", payload: parseUrl() });
36
50
  };
37
51
  window.addEventListener("popstate", onPopState);
38
- // Seed from current URL on init — deferred so the Machine constructor finishes first.
39
52
  queueMicrotask(() => {
40
53
  const initial = parseUrl();
41
- if (initial.correlationId || initial.handler) {
54
+ if (initial.workflowId || initial.handler || initial.subject) {
42
55
  dispatch({ type: "location/changed", payload: initial });
43
56
  }
44
57
  });
@@ -46,21 +59,36 @@ export const createUrlEngine = (dispatch, _getState) => {
46
59
  handleEvent: (event) => {
47
60
  switch (event.type) {
48
61
  case "ui/flow_opened":
49
- window.history.pushState(null, "", buildSearch(event.payload.correlationId, null));
62
+ window.history.pushState(null, "", buildSearch(event.payload.workflowId, null));
50
63
  break;
51
64
  case "ui/flow_closed":
52
65
  window.history.pushState(null, "", buildSearch(null, null));
53
66
  break;
54
67
  case "ui/filter_changed": {
55
68
  const payload = event.payload;
56
- if (payload.correlationId !== undefined) {
57
- window.history.pushState(null, "", buildSearch(payload.correlationId, null));
69
+ if (payload.workflowId !== undefined) {
70
+ window.history.pushState(null, "", buildSearch(payload.workflowId, null));
58
71
  }
59
72
  break;
60
73
  }
61
74
  case "ui/handler_selected":
62
- // Replace rather than push — handler changes within a flow are fine as one history entry
63
- window.history.replaceState(null, "", buildSearch(new URLSearchParams(window.location.search).get("correlation"), event.payload.reactorId));
75
+ window.history.replaceState(null, "", buildSearch(new URLSearchParams(window.location.search).get("workflow"), event.payload.reactorId));
76
+ break;
77
+ case "ui/subject_selected":
78
+ window.history.pushState(null, "", buildSubjectSearch(event.payload.aggregateType, event.payload.aggregateId, event.payload.mode ?? "both"));
79
+ break;
80
+ case "ui/subject_mode_changed":
81
+ window.history.replaceState(null, "", (() => {
82
+ const params = new URLSearchParams(window.location.search);
83
+ const existing = params.get("subject");
84
+ if (!existing)
85
+ return window.location.href;
86
+ if (event.payload.mode === "both")
87
+ params.delete("subjectMode");
88
+ else
89
+ params.set("subjectMode", event.payload.mode);
90
+ return `${window.location.pathname}?${params.toString()}`;
91
+ })());
64
92
  break;
65
93
  }
66
94
  },