causal-inspector 0.1.6 → 0.2.1

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.
Files changed (39) hide show
  1. package/README.md +71 -0
  2. package/dist/CausalInspector.css +20 -447
  3. package/dist/CausalInspector.d.ts +8 -1
  4. package/dist/CausalInspector.js +32 -9
  5. package/dist/causal-inspector.css +2899 -0
  6. package/dist/components/CopyablePayload.js +8 -8
  7. package/dist/components/EffectList.d.ts +4 -0
  8. package/dist/components/EffectList.js +15 -0
  9. package/dist/components/FilterBar.js +7 -10
  10. package/dist/components/GlobalScrubber.js +6 -6
  11. package/dist/components/JsonSyntax.js +8 -8
  12. package/dist/engines/query.js +131 -52
  13. package/dist/engines/scrubber.js +1 -1
  14. package/dist/engines/url.d.ts +5 -2
  15. package/dist/engines/url.js +50 -22
  16. package/dist/events.d.ts +39 -13
  17. package/dist/index.d.ts +5 -3
  18. package/dist/index.js +4 -2
  19. package/dist/panes/AggregateTimelinePane.js +4 -4
  20. package/dist/panes/CausalFlowPane.js +39 -27
  21. package/dist/panes/CausalTreePane.js +43 -34
  22. package/dist/panes/LogsPane.js +9 -17
  23. package/dist/panes/SubjectChainPane.d.ts +1 -0
  24. package/dist/panes/SubjectChainPane.js +50 -0
  25. package/dist/panes/TimelinePane.js +33 -19
  26. package/dist/panes/WaterfallPane.js +5 -5
  27. package/dist/panes/WorkflowExplorerPane.d.ts +2 -0
  28. package/dist/panes/WorkflowExplorerPane.js +49 -0
  29. package/dist/queries.d.ts +16 -12
  30. package/dist/queries.js +103 -27
  31. package/dist/reducer.js +134 -38
  32. package/dist/state.d.ts +18 -5
  33. package/dist/state.js +17 -8
  34. package/dist/theme.js +4 -4
  35. package/dist/types.d.ts +52 -12
  36. package/dist/utils.js +1 -1
  37. package/package.json +18 -3
  38. package/dist/panes/CorrelationExplorerPane.d.ts +0 -2
  39. package/dist/panes/CorrelationExplorerPane.js +0 -51
@@ -22,23 +22,23 @@ function PayloadModal({ formatted, onClose, }) {
22
22
  document.addEventListener("keydown", handleKey);
23
23
  return () => document.removeEventListener("keydown", handleKey);
24
24
  }, [onClose]);
25
- return createPortal(_jsx("div", { className: "causal-inspector", children: _jsx("div", { className: "ci-modal-overlay", onClick: onClose, children: _jsxs("div", { className: "ci-modal-content", onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { style: { position: "absolute", top: 12, right: 12, display: "flex", gap: 4 }, children: [_jsx("button", { onClick: async () => {
26
- await copyToClipboard(formatted);
27
- setCopied(true);
28
- setTimeout(() => setCopied(false), 1500);
29
- }, className: "ci-modal-btn", title: "Copy payload", children: copied ? _jsx(Check, { size: 14 }) : _jsx(Copy, { size: 14 }) }), _jsx("button", { onClick: onClose, className: "ci-modal-btn", title: "Close", children: _jsx(X, { size: 14 }) })] }), _jsx("pre", { style: { fontSize: 12, whiteSpace: "pre-wrap" }, children: _jsx(JsonSyntax, { json: formatted }) })] }) }) }), document.body);
25
+ return createPortal(_jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center", style: { background: "rgba(0, 0, 0, 0.6)", backdropFilter: "blur(4px)" }, onClick: onClose, children: _jsxs("div", { className: "relative w-[90vw] max-h-[90vh] overflow-auto rounded-xl border border-border p-5", style: { background: "rgba(15, 15, 20, 0.95)", boxShadow: "0 24px 64px rgba(0, 0, 0, 0.5)" }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "absolute top-3 right-3 flex gap-1", children: [_jsx("button", { onClick: async () => {
26
+ await copyToClipboard(formatted);
27
+ setCopied(true);
28
+ setTimeout(() => setCopied(false), 1500);
29
+ }, className: "p-1.5 rounded-md hover:bg-white/[0.05] transition-colors text-xs text-muted-foreground/50 hover:text-foreground", title: "Copy payload", children: copied ? _jsx(Check, { size: 14 }) : _jsx(Copy, { size: 14 }) }), _jsx("button", { onClick: onClose, className: "p-1.5 rounded-md hover:bg-white/[0.05] transition-colors text-xs text-muted-foreground/50 hover:text-foreground", title: "Close", children: _jsx(X, { size: 14 }) })] }), _jsx("pre", { className: "text-xs whitespace-pre-wrap", children: _jsx(JsonSyntax, { json: formatted }) })] }) }), document.body);
30
30
  }
31
31
  export function CopyablePayload({ payload, className = "", }) {
32
32
  const [copied, setCopied] = useState(false);
33
33
  const [modalOpen, setModalOpen] = useState(false);
34
34
  const formatted = formatPayload(payload);
35
- return (_jsxs("div", { style: { position: "relative" }, className: className, children: [_jsx("pre", { className: "ci-payload", style: { minHeight: 96, maxHeight: "80vh" }, children: _jsx(JsonSyntax, { json: formatted }) }), _jsxs("div", { style: { position: "absolute", top: 8, right: 8, zIndex: 10, display: "flex", gap: 4 }, children: [_jsx("button", { onClick: (e) => {
35
+ return (_jsxs("div", { className: `relative ${className}`, children: [_jsx("pre", { className: "p-2.5 text-[10px] rounded-md border border-border overflow-auto whitespace-pre-wrap resize-y min-h-24 max-h-[80vh]", style: { background: "rgba(255, 255, 255, 0.015)" }, children: _jsx(JsonSyntax, { json: formatted }) }), _jsxs("div", { className: "absolute top-2 right-2 z-10 flex gap-1", children: [_jsx("button", { onClick: (e) => {
36
36
  e.stopPropagation();
37
37
  setModalOpen(true);
38
- }, className: "ci-payload-btn", title: "Expand", children: _jsx(Maximize2, { size: 12 }) }), _jsx("button", { onClick: async (e) => {
38
+ }, className: "p-1 rounded-md border border-border hover:bg-white/[0.05] transition-all text-[10px] text-muted-foreground/40 hover:text-muted-foreground", style: { background: "rgba(10, 10, 15, 0.8)", backdropFilter: "blur(4px)" }, title: "Expand", children: _jsx(Maximize2, { size: 12 }) }), _jsx("button", { onClick: async (e) => {
39
39
  e.stopPropagation();
40
40
  await copyToClipboard(formatted);
41
41
  setCopied(true);
42
42
  setTimeout(() => setCopied(false), 1500);
43
- }, className: "ci-payload-btn", title: "Copy", children: copied ? _jsx(Check, { size: 12 }) : _jsx(Copy, { size: 12 }) })] }), modalOpen && (_jsx(PayloadModal, { formatted: formatted, onClose: () => setModalOpen(false) }))] }));
43
+ }, className: "p-1 rounded-md border border-border hover:bg-white/[0.05] transition-all text-[10px] text-muted-foreground/40 hover:text-muted-foreground", style: { background: "rgba(10, 10, 15, 0.8)", backdropFilter: "blur(4px)" }, title: "Copy", children: copied ? _jsx(Check, { size: 12 }) : _jsx(Copy, { size: 12 }) })] }), modalOpen && (_jsx(PayloadModal, { formatted: formatted, onClose: () => setModalOpen(false) }))] }));
44
44
  }
@@ -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
+ }
@@ -4,17 +4,14 @@ import { Search, X } from "lucide-react";
4
4
  export function FilterBar() {
5
5
  const filters = useSelector((s) => s.filters);
6
6
  const dispatch = useDispatch();
7
- return (_jsxs("div", { className: "ci-row ci-surface", style: { flexWrap: "wrap", gap: 8, padding: "8px 12px", borderBottom: "1px solid var(--ci-border)" }, children: [_jsxs("div", { className: "ci-row", style: { position: "relative" }, children: [_jsx(Search, { size: 12, style: { position: "absolute", left: 10, color: "var(--ci-text-muted)", pointerEvents: "none" } }), _jsx("input", { type: "text", placeholder: "search events...", value: filters.search, onChange: (e) => dispatch({
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: "ci-input", style: { paddingLeft: 28, paddingRight: 8, paddingTop: 6, paddingBottom: 6, fontSize: 12, width: 256 } })] }), _jsx("span", { className: "ci-divider-h" }), _jsx("label", { style: { fontSize: 10, color: "var(--ci-text-muted)", textTransform: "uppercase", letterSpacing: "0.05em" }, children: "From" }), _jsx("input", { type: "date", value: filters.from ?? "", onChange: (e) => dispatch({
11
- type: "ui/filter_changed",
12
- payload: { from: e.target.value || null },
13
- }), className: "ci-input", style: { padding: "6px 8px", fontSize: 12, width: 128 } }), _jsx("label", { style: { fontSize: 10, color: "var(--ci-text-muted)", textTransform: "uppercase", letterSpacing: "0.05em" }, children: "To" }), _jsx("input", { type: "date", value: filters.to ?? "", onChange: (e) => dispatch({
14
- type: "ui/filter_changed",
15
- payload: { to: e.target.value || null },
16
- }), className: "ci-input", style: { padding: "6px 8px", fontSize: 12, width: 128 } }), filters.correlationId && (_jsxs(_Fragment, { children: [_jsx("span", { className: "ci-divider-h" }), _jsxs("span", { className: "ci-badge ci-badge--purple", 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({
17
11
  type: "ui/filter_changed",
18
- payload: { correlationId: null },
19
- }), style: { color: "inherit", background: "none", border: "none", cursor: "pointer", padding: 0, transition: "color 150ms" }, children: _jsx(X, { size: 10 }) })] })] }))] }));
12
+ payload: { workflowId: null },
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
+ type: "ui/filter_changed",
15
+ payload: { aggregateKey: null },
16
+ }), className: "hover:text-foreground transition-colors", children: _jsx(X, { size: 10 }) })] })] }))] }));
20
17
  }
@@ -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
@@ -9,23 +9,23 @@ function tokenizeJson(json) {
9
9
  tokens.push({ text: json.slice(lastIndex, match.index), color: "" });
10
10
  }
11
11
  if (match[1] !== undefined) {
12
- tokens.push({ text: match[1], color: "ci-json-key" });
13
- tokens.push({ text: ":", color: "ci-json-punct" });
12
+ tokens.push({ text: match[1], color: "text-blue-400" });
13
+ tokens.push({ text: ":", color: "text-zinc-500" });
14
14
  }
15
15
  else if (match[2] !== undefined) {
16
- tokens.push({ text: match[2], color: "ci-json-string" });
16
+ tokens.push({ text: match[2], color: "text-green-400" });
17
17
  }
18
18
  else if (match[3] !== undefined) {
19
- tokens.push({ text: match[3], color: "ci-json-number" });
19
+ tokens.push({ text: match[3], color: "text-amber-400" });
20
20
  }
21
21
  else if (match[4] !== undefined) {
22
- tokens.push({ text: match[4], color: "ci-json-bool" });
22
+ tokens.push({ text: match[4], color: "text-purple-400" });
23
23
  }
24
24
  else if (match[5] !== undefined) {
25
- tokens.push({ text: match[5], color: "ci-json-null" });
25
+ tokens.push({ text: match[5], color: "text-zinc-500" });
26
26
  }
27
27
  else if (match[6] !== undefined) {
28
- tokens.push({ text: match[6], color: "ci-json-punct" });
28
+ tokens.push({ text: match[6], color: "text-zinc-500" });
29
29
  }
30
30
  lastIndex = re.lastIndex;
31
31
  }
@@ -36,5 +36,5 @@ function tokenizeJson(json) {
36
36
  }
37
37
  export function JsonSyntax({ json }) {
38
38
  const tokens = tokenizeJson(json);
39
- return (_jsx(_Fragment, { children: tokens.map((t, i) => (_jsx("span", { className: t.color || undefined, children: t.text }, i))) }));
39
+ return (_jsx(_Fragment, { children: tokens.map((t, i) => (_jsx("span", { className: t.color, children: t.text }, i))) }));
40
40
  }
@@ -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, INSPECTOR_REACTOR_ATTEMPTS, } 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,49 +68,49 @@ 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
73
  const [descData, snapshotData, aggTimelineData, outcomeData, attemptData] = 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 }),
77
- transport.query(INSPECTOR_REACTOR_ATTEMPTS, { correlationId }),
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 }),
78
79
  ]);
79
- if (activeFlowCorrelationId !== correlationId)
80
+ if (activeFlowWorkflowId !== workflowId)
80
81
  return; // stale
81
82
  dispatch({
82
83
  type: "events/descriptions_loaded",
83
84
  payload: {
84
- correlationId,
85
+ workflowId,
85
86
  descriptions: descData.inspectorReactorDescriptions,
86
87
  },
87
88
  });
88
89
  dispatch({
89
90
  type: "events/description_snapshots_loaded",
90
91
  payload: {
91
- correlationId,
92
+ workflowId,
92
93
  snapshots: snapshotData.inspectorReactorDescriptionSnapshots,
93
94
  },
94
95
  });
95
96
  dispatch({
96
97
  type: "events/aggregate_timeline_loaded",
97
98
  payload: {
98
- correlationId,
99
+ workflowId,
99
100
  entries: aggTimelineData.inspectorAggregateTimeline,
100
101
  },
101
102
  });
102
103
  dispatch({
103
104
  type: "events/outcomes_loaded",
104
105
  payload: {
105
- correlationId,
106
+ workflowId,
106
107
  outcomes: outcomeData.inspectorReactorOutcomes,
107
108
  },
108
109
  });
109
110
  dispatch({
110
111
  type: "events/attempts_loaded",
111
112
  payload: {
112
- correlationId,
113
+ workflowId,
113
114
  attempts: attemptData.inspectorReactorAttempts,
114
115
  },
115
116
  });
@@ -118,32 +119,37 @@ export const createQueryEngine = (transport) => {
118
119
  console.error("[causal-inspector] fetch flow metadata failed:", e);
119
120
  }
120
121
  };
121
- const fetchLogs = async (correlationId) => {
122
+ const fetchLogs = async (workflowId) => {
122
123
  try {
123
- const data = await transport.query(INSPECTOR_REACTOR_LOGS_BY_CORRELATION, { correlationId });
124
- 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 });
125
126
  }
126
127
  catch (e) {
127
128
  console.error("[causal-inspector] fetch logs failed:", e);
128
129
  }
129
130
  };
130
- const fetchCorrelations = async (opts) => {
131
+ let workflowCursor = null;
132
+ const fetchWorkflows = async (opts) => {
133
+ const append = opts?.append ?? false;
134
+ const cursor = append ? workflowCursor : undefined;
131
135
  try {
132
- const data = await transport.query(INSPECTOR_CORRELATIONS, {
136
+ const data = await transport.query(INSPECTOR_WORKFLOWS, {
133
137
  search: opts?.search || undefined,
134
- limit: 100,
138
+ limit: 50,
139
+ cursor: cursor || undefined,
135
140
  });
141
+ workflowCursor = data.inspectorWorkflows.nextCursor;
136
142
  dispatch({
137
- type: "events/correlations_loaded",
143
+ type: "events/workflows_loaded",
138
144
  payload: {
139
- correlations: data.inspectorCorrelations,
140
- hasMore: false,
141
- append: false,
145
+ workflows: data.inspectorWorkflows.workflows,
146
+ hasMore: data.inspectorWorkflows.nextCursor != null,
147
+ append,
142
148
  },
143
149
  });
144
150
  }
145
151
  catch (e) {
146
- console.error("[causal-inspector] fetch correlations failed:", e);
152
+ console.error("[causal-inspector] fetch workflows failed:", e);
147
153
  }
148
154
  };
149
155
  const fetchReactorDependencies = async () => {
@@ -182,10 +188,54 @@ export const createQueryEngine = (transport) => {
182
188
  console.error("[causal-inspector] fetch aggregate lifecycle failed:", e);
183
189
  }
184
190
  };
185
- 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) => {
186
236
  stopFlowPolling();
187
- fetchFlowMetadata(correlationId);
188
- flowPollTimer = setInterval(() => fetchFlowMetadata(correlationId), 5000);
237
+ fetchFlowMetadata(workflowId);
238
+ flowPollTimer = setInterval(() => fetchFlowMetadata(workflowId), 5000);
189
239
  };
190
240
  const stopFlowPolling = () => {
191
241
  if (flowPollTimer) {
@@ -193,27 +243,33 @@ export const createQueryEngine = (transport) => {
193
243
  flowPollTimer = null;
194
244
  }
195
245
  };
196
- const stopCorrelationPolling = () => {
197
- if (correlationPollTimer) {
198
- clearInterval(correlationPollTimer);
199
- correlationPollTimer = null;
246
+ const stopWorkflowPolling = () => {
247
+ if (workflowPollTimer) {
248
+ clearInterval(workflowPollTimer);
249
+ workflowPollTimer = null;
200
250
  }
201
251
  };
202
252
  // Initial load
203
253
  fetchEvents();
204
- fetchCorrelations();
254
+ fetchWorkflows();
205
255
  fetchReactorDependencies();
206
256
  fetchAggregateKeys();
207
257
  return {
208
258
  handleEvent: (event, curr, prev) => {
209
259
  // ── State-reactive: navigation transitions ──
210
- if (curr.flowCorrelationId !== prev.flowCorrelationId) {
211
- 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) {
212
268
  // Flow opened
213
- fetchFlow(curr.flowCorrelationId);
214
- startFlowPolling(curr.flowCorrelationId);
215
- fetchLogs(curr.flowCorrelationId);
216
- stopCorrelationPolling();
269
+ fetchFlow(curr.flowWorkflowId);
270
+ startFlowPolling(curr.flowWorkflowId);
271
+ fetchLogs(curr.flowWorkflowId);
272
+ stopWorkflowPolling();
217
273
  }
218
274
  else {
219
275
  // Flow closed
@@ -231,22 +287,45 @@ export const createQueryEngine = (transport) => {
231
287
  case "ui/filter_changed":
232
288
  fetchEvents();
233
289
  break;
234
- case "ui/load_more_correlations_requested":
235
- fetchCorrelations();
290
+ case "ui/load_more_workflows_requested":
291
+ fetchWorkflows({ append: true });
236
292
  break;
237
- case "ui/correlations_requested":
238
- fetchCorrelations({ search: event.payload.search });
239
- stopCorrelationPolling();
240
- 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);
241
298
  break;
242
299
  case "ui/aggregate_lifecycle_requested":
243
300
  fetchAggregateLifecycle(event.payload.aggregateKey);
244
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;
245
324
  }
246
325
  },
247
326
  dispose: () => {
248
327
  stopFlowPolling();
249
- stopCorrelationPolling();
328
+ stopWorkflowPolling();
250
329
  },
251
330
  };
252
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
  },